← All posts

Deploying a Go Web Service: A Minimal Production Checklist

October 26, 2026

TL;DR: A Go service is production-ready when it listens on 0.0.0.0 and the PORT variable, sets server timeouts, shuts down cleanly on SIGTERM, logs to stdout and reads every setting from the environment. Go makes the binary easy; these few lines of discipline are what keep it running once real traffic arrives.

This checklist assumes a plain net/http service with Postgres, Go 1.22 or newer, and a git-based deploy. Everything here works the same with chi, echo or gin, because they all sit on top of http.Server.

What does a deployable Go repo look like?

Keep it boring:

myservice/
├── go.mod
├── go.sum
├── main.go
├── internal/
│   ├── db/
│   └── handlers/
└── migrations/

Commit go.sum. Without it, builds can resolve different dependency versions than the ones you tested, and some build tools refuse to run at all.

On Candor Deploy, a go.mod at the root of the directory being built marks it as a Go app, and the build runs on Nixpacks with no configuration (builds and configuration docs). For a monorepo, point the app at its subdirectory instead of moving files around.

Which port and address should a Go server listen on?

This is the most common first-deploy failure, in any language. Locally you probably wrote http.ListenAndServe(":8080", mux) or, worse, "localhost:8080". In a container, localhost means “only reachable from inside this container”, so the platform’s router can’t get in.

Read the port from the environment and bind to all interfaces:

port := os.Getenv("PORT")
if port == "" {
    port = "8080" // local default
}
addr := "0.0.0.0:" + port

Candor Deploy always sets PORT (8080 by default), routes traffic to it and health-checks the app by connecting to it. An app that listens somewhere else sits in Deploying and never goes live, while the previous version keeps serving (troubleshooting docs). We wrote up the full story in the $PORT and 0.0.0.0 fix.

Why do Go servers need explicit timeouts?

http.ListenAndServe uses a zero-value http.Server, which has no timeouts. A slow or malicious client can hold a connection open indefinitely, and enough of those exhaust file descriptors or memory. Build the server yourself:

srv := &http.Server{
    Addr:              addr,
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
}

Pick numbers that fit your slowest legitimate request. If one endpoint streams or handles big uploads, give it its own handling with http.ResponseController rather than raising the global limit for everything.

How do you shut a Go service down gracefully?

When a new version rolls out, the old process gets stopped. Container runtimes generally send SIGTERM first and force-kill a little later. If your service exits instantly, in-flight requests get cut off. If it ignores the signal, it gets killed anyway.

Handle it explicitly:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatalf("listen: %v", err)
    }
}()

<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("shutdown: %v", err)
}
db.Close()

Shutdown stops accepting new connections and waits for active ones to finish, up to your deadline. Close the database pool after the server, not before, so the last requests can still query.

On Candor Deploy, the new version starts alongside the old one and has to accept connections on its port before traffic switches, and only then is the old version retired (builds and configuration docs). Clean shutdown on your side means those retiring requests finish instead of erroring.

Where should configuration come from?

From the environment, every time. Hard-coded DSNs and API keys end up in git history, and per-environment config files drift.

A small helper keeps it tidy:

func mustEnv(key string) string {
    v := os.Getenv(key)
    if v == "" {
        log.Fatalf("missing required env var %s", key)
    }
    return v
}

dbURL := mustEnv("DATABASE_URL")

Failing loudly at startup is a feature. A missing variable shows up as one clear line in the runtime log instead of a confusing nil-pointer panic on the first request.

When you attach a Postgres database to an app on Candor Deploy, its connection string lands in the app’s environment as DATABASE_URL, and the change reaches the running app on its next deploy (databases docs). App-specific settings can go in the portal’s Environment tab or in the env block of a .candorfile (.candorfile docs).

How should a Go service connect to Postgres?

Use a pool and set its limits. With pgx:

cfg, err := pgxpool.ParseConfig(dbURL)
if err != nil {
    log.Fatalf("db config: %v", err)
}
cfg.MaxConns = 10
cfg.MaxConnIdleTime = 5 * time.Minute

pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
    log.Fatalf("db connect: %v", err)
}

Two things matter more than the exact numbers. First, keep MaxConns well below the database’s connection limit, especially if you run more than one instance or a separate worker. Second, don’t fail forever if the database is a few seconds slower to accept connections than your app is to start. A short retry loop with a deadline is enough.

A database on Candor Deploy is never exposed to the internet; only your own services can reach it (databases docs). That’s the right default, and it means you connect through DATABASE_URL from inside the app rather than from your laptop’s psql against a public host.

What about database migrations?

Run them as a separate, explicit step before the new code starts serving, and write them so the old version still works against the new schema. Tools like goose, golang-migrate or atlas all support a migrate up command you can call from a start script, or from a subcommand of your own binary (./myservice migrate && ./myservice serve).

The pattern that keeps rollouts safe is expand, then contract: add new columns and tables first, ship code that uses them, and remove old ones only in a later deploy. We covered it step by step in running database migrations on deploy safely.

What should a Go service log?

Write structured logs to stdout and let the platform collect them. The standard library has this built in since Go 1.21:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("starting", "addr", addr)

Log one line when you start listening, including the address. When a deploy hangs, that single line tells you right away whether the process started and where it’s listening. On Candor Deploy, build output is on the Deployments tab and what your app prints after it starts is on the Logs tab (troubleshooting docs).

Do you need a Dockerfile for Go?

Usually not. Go compiles to a single binary, and auto-detected builds handle go build well. Reach for a Dockerfile when you need something specific: CGO with system libraries, a distroless or scratch runtime image, or a build that has to match your CI exactly.

On Candor Deploy, a Dockerfile in your repo is ignored unless you opt in with builder: dockerfile (or a dockerfile: path) in a .candorfile (builds and configuration docs). That’s deliberate: many repos carry Dockerfiles meant for local development, not production.

The checklist

Before your first deploy, confirm each of these:

  • go.mod and go.sum are committed.
  • The server listens on 0.0.0.0 and os.Getenv("PORT").
  • http.Server has read, write, header and idle timeouts.
  • SIGTERM triggers srv.Shutdown with a deadline, then the DB pool closes.
  • Every secret and setting comes from the environment, and required ones fail fast.
  • The Postgres pool has a MaxConns below the database’s limit.
  • Migrations run before serving and follow expand, then contract.
  • Logs go to stdout, with one clear “listening on” line at startup.

Ready to ship it?

If your repo passes the list above, the Candor Deploy quickstart walks you from connecting the repo to a live URL.