Skip to content
CalliCoder

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.