Docker Compose for a Multi-Container Go Application
DevOps 14 min read
depends_on without a condition only waits for the container to start, not for the service to be ready. Plus the watch mode that replaced bind-mounting source, why the version key now warns, and the SIGTERM your Go binary has ten seconds to handle.
Compose describes several containers, their network and their startup order in one file. The one
thing it does not do by default is wait for a service to be ready: depends_on waits for the
container to start, which for a database is several seconds before it accepts connections. A Go
service that connects at startup will fail on the first docker compose up and succeed on the
second, which is the single most common Compose problem.
Written against Compose v2.24, Go 1.22 and Postgres 16.
The application
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
slog.Error("opening database", "err", err)
os.Exit(1)
}
defer db.Close()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ok")
})
server := &http.Server{Addr: ":8080", Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("serving", "err", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}
sql.Open does not connect. It validates the DSN and returns a lazy pool. That is convenient
here: the process starts even when the database is not up yet, and the first query is what fails. It
also means a misconfigured DATABASE_URL produces no error at startup, which is worth knowing.
The signal handling matters for Compose specifically, covered below.
The Dockerfile
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/api ./cmd/api
FROM gcr.io/distroless/static-nonroot
COPY --from=build /bin/api /api
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/api"]
Copying go.mod and go.sum before the source is what makes the dependency layer cacheable: the
image-building walkthrough covers why, and why
CGO_ENABLED=0 decides whether the binary runs on distroless at all.
The compose file
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://app:secret@db:5432/appdb?sslmode=disable
depends_on:
db:
condition: service_healthy # waits for the healthcheck, not just the start
healthcheck:
test: ["CMD", "/api", "-healthcheck"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
No version: key. It has been ignored since Compose v2 and now emits a warning: files still
carrying version: "3.8" are copied from pre-2022 documentation.
depends_on is not “wait for ready”
depends_on:
- db # waits only for the container to START
depends_on:
db:
condition: service_healthy # waits for the healthcheck to PASS
The short form starts db first and then immediately starts api. Postgres takes a few seconds to
initialise, so the API’s first query fails.
The long form with service_healthy requires the depended-on service to declare a healthcheck, and
Compose waits for it. Without a healthcheck on db, the condition is an error rather than being
silently ignored, which is the right behaviour.
Three conditions exist: service_started (the default, equivalent to the short form),
service_healthy, and service_completed_successfully for a one-shot migration container.
depends_on only orders startup. It does nothing at runtime, if Postgres restarts an hour later,
the API is not restarted or notified. Application-level retry is still required, and the healthcheck
plus restart, unless-stopped is what makes the system recover.
pg_isready uses CMD-SHELL because it needs shell semantics; the API’s own check uses CMD with an
exec-form array, which is the safer default, no shell, no word splitting.
Migrations as a one-shot service
migrate:
image: migrate/migrate
command: ["-path", "/migrations", "-database", "postgres://app:secret@db:5432/appdb?sslmode=disable", "up"]
volumes:
- ./migrations:/migrations:ro
depends_on:
db:
condition: service_healthy
api:
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
service_completed_successfully waits for the container to exit with status 0. A failing migration
therefore stops the API from starting, which is what you want, the alternative is an API serving
against a schema it does not match.
Networking uses service names
postgres://app:secret@db:5432/appdb, the host is db, the service name. Compose creates a network
per project and registers each service under its name, so containers reach each other without
published ports.
ports: publishes to the host, and only the API needs it. Publishing 5432:5432 on the database
exposes it to the whole machine, which for a development stack means a second project’s Postgres
cannot start on the same port.
The port to use between services is the container port, not the host mapping. "8080:8080" makes
the API reachable at localhost:8080 from the host and at api:8080 from another container. Using
the host port inside a container is the second most common Compose mistake.
Live reload: watch, not bind mounts
The old approach bind-mounted the source and ran go run in the container. Compose 2.22 added a
better mechanism:
api:
develop:
watch:
- action: rebuild
path: ./cmd
- action: rebuild
path: ./internal
- action: sync
path: ./migrations
target: /migrations
docker compose up --watch
rebuild rebuilds the image and restarts the service when files under that path change. sync
copies changed files into the running container without a restart, which suits templates and
migrations and does nothing useful for compiled Go.
For a compiled language rebuild is the honest action, and the layer cache makes it fast, only the
final go build step re-runs. Bind-mounting source into a Go container instead means the container
needs the toolchain, which puts the development image several hundred megabytes above the production
one and lets them diverge.
Profiles keep optional services out of the way
pgadmin:
image: dpage/pgadmin4
profiles: ["tools"]
environment:
PGADMIN_DEFAULT_EMAIL: [email protected]
PGADMIN_DEFAULT_PASSWORD: dev
A service with a profile does not start unless the profile is requested:
docker compose up # api and db only
docker compose --profile tools up # plus pgadmin
This is how to keep an admin UI, a mail catcher or a load generator in the same file without paying
for them on every up.
Shutdown, and the ten seconds
docker compose down # stop and remove containers and networks
docker compose down -v # ALSO delete the named volumes — destroys the database
docker compose stop # stop without removing
docker compose down sends SIGTERM and waits ten seconds before SIGKILL. That is why the Go
program above uses signal.NotifyContext and an eight-second shutdown budget: a process that ignores
SIGTERM is killed, in-flight requests are dropped, and buffered writes are lost.
stop_grace_period: 30s on a service raises the window when shutdown genuinely takes longer.
The -v flag is the one to be careful with: it deletes pgdata and every row in it, with no
confirmation.
Reading what happened
docker compose ps # state and health of each service
docker compose logs -f api # follow one service
docker compose logs --tail=50 # everything, recent
docker compose exec api sh # a shell in a RUNNING container
docker compose run --rm api sh # a NEW throwaway container
docker compose config # the merged, resolved file
exec against a distroless image fails. There is no shell in it. That is the intended trade;
diagnose from the logs and the healthcheck, or build a debug variant with a shell.
docker compose config is the command worth knowing when something is not behaving: it prints the
file after variable substitution and override merging, which is what Compose actually acts on.
Compare with the same stack in Kubernetes, and the Spring Boot and React Compose stack for a three-service example. More in the DevOps guides.
Frequently asked questions
Why does my app fail on the first up and work on the second?
depends_on in its short form
waits only for the container to start, not for the database to accept connections. Use
condition: service_healthy with a healthcheck on the dependency.
Do I still need the version key?
No. It has been ignored since Compose v2 and now produces a warning. Delete it.
What are the depends_on conditions?
service_started (the default), service_healthy (waits for
the healthcheck), and service_completed_successfully (waits for a one-shot container to exit 0).
Does depends_on restart my service if a dependency dies?
No, it only orders startup. Runtime recovery needs a healthcheck, a restart policy and application-level retry.
Which hostname do containers use for each other?
The service name, on the container port —
db:5432. The ports: mapping is for reaching the service from the host.
Should I publish the database port?
Not usually. Containers reach it by service name without it, and publishing it occupies the host port for every other project.
How do I get live reload for Go?
develop.watch with action: rebuild and docker compose up --watch. Bind-mounting source requires the toolchain in the container and makes the development
image diverge from production.
What is the difference between exec and run?
exec enters a running container; run starts a new
one. run --rm is for one-off commands, and it does not reuse the running service.
Why can’t I get a shell in my container?
A distroless or scratch image contains no shell. Read the logs, or build a separate debug image.
How much time does my process get to shut down?
Ten seconds after SIGTERM, then SIGKILL. Handle
SIGTERM, finish in less than that, or raise stop_grace_period.