Deploy a Go service
Go is the least eventful deployment on the platform. The build compiles your module and the image runs the resulting binary. There is no runtime to install, no package manager at boot and no framework conventions to satisfy.
mkdir my-service && cd my-service
go mod init github.com/you/my-service
Write a main.go that reads PORT, push it, and connect the repository as in the quickstart. zeitlos/go-example(opens in a new tab) is the whole thing in one file:
package main
import (
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Deployed on Lucity"))
})
log.Fatal(http.ListenAndServe(":"+port, nil))
}
What the build does
Detection keys on go.mod, a Go workspace, or a main.go at the root. The build compiles with go build and the service starts the binary it produced. The Go toolchain version comes from the go directive in your go.mod, so bumping Go is a one-line change in the file that already declares it.
Because the runtime image carries a binary rather than a toolchain, Go services start fast and use very little memory. A service that idles at 300MB in a JavaScript runtime often idles under 20MB here, which is worth remembering when you set resources.
Repositories with more than one binary
A cmd/ directory with several programs is the common case that needs a decision, because only one of them is your service. There is no build command field in the dashboard, but variables reach the builder, and the builder reads one that replaces the compile step:
| Variable | Value |
|---|---|
RAILPACK_BUILD_CMD | go build -o out ./cmd/api |
Keep the output name as out: the derived start command runs ./out, so a different name leaves nothing to start. Set that variable and the build compiles the package you named instead of guessing.
Deploy the other binaries as their own services from the same repository, each with its own RAILPACK_BUILD_CMD. A worker and an API sharing a module, deployed separately and scaled separately, is a natural fit for the way projects work here.
Health checks earn their keep
The default check opens a TCP connection to your port. A Go service that has crashed stops accepting connections, so this catches the obvious failure. It does not catch a service whose database pool is exhausted while the listener stays open.
Add an endpoint that answers what you actually care about, then point the service health check at it:
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "database unreachable", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
})
Keep the check cheap. It runs on a schedule, and a slow check under load turns into a restart under load.
Connecting a database
Add a PostgreSQL database and link DATABASE_URL to its fqdn-uri:
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
Set MaxConns deliberately. The limit that matters is per replica, so four replicas with a pool of 25 each is 100 connections against a database that may allow fewer.
Shutting down cleanly
Rollouts send SIGTERM and then wait before killing the container. Handling it means in-flight requests finish instead of failing:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() { log.Fatal(server.ListenAndServe()) }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
Without it, every deploy drops whatever was in flight. With it, deploys are invisible to users.
Next steps
- Services for resources, replicas and health checks
- Deployments for rollout and rollback behavior
- Metrics to see how little memory it actually uses