How to Dockerize a Go API with a Database

Dockerize a Go API with a database using a multi-stage Dockerfile and docker-compose to manage the API and PostgreSQL services.

The local machine illusion

You finish a Go API that reads from PostgreSQL. It runs perfectly on your laptop. You zip the source code, send it to a colleague, and watch it fail. Their machine lacks the exact Go version. Their PostgreSQL runs on a different port. The connection string points to localhost, which suddenly means the wrong machine. The code is fine. The environment is not.

Docker removes the environment from the equation. You package the Go binary, the runtime dependencies, and the configuration into a single reproducible unit. Docker Compose adds a second layer: it defines how that unit talks to a database container, maps ports to your host, and passes secrets through environment variables. The result is a setup that runs identically on your machine, a staging server, or a cloud instance.

Containers are not virtual machines. They share the host kernel and only isolate the filesystem, network, and process tree. That makes them fast to start and lightweight to run. The tradeoff is that you must understand how the isolation works. When you get it right, deployment stops being guesswork.

Containers as shipping crates

Think of a Docker image as a standardized shipping crate. Inside the crate sits your compiled Go binary, a minimal Linux filesystem, and the exact command that starts the process. The crate has a fixed size, a known weight, and a clear label. You can stack it, move it, or run it anywhere that supports the container runtime.

Docker Compose is the loading dock blueprint. It tells the runtime which crates to pull, how to connect them with a private network, which host ports to expose, and what environment variables to inject before the engines start. You write the blueprint once in YAML. The runtime executes it deterministically.

Go compiles to a single static binary by default. That binary contains the entire standard library and your code. You do not need to ship the Go toolchain inside the final container. You only need the compiler during the build phase. This separation is the foundation of every efficient Go Docker setup.

The minimal setup

Start with a single Dockerfile that compiles the binary and a compose file that wires it to a database. This is the baseline that proves the concept before you add production hardening.

Here is the simplest Dockerfile that builds a Go binary and runs it in a lightweight image:

# Use the official Go image with Alpine Linux for the build stage
FROM golang:1.23-alpine AS builder
# Set the working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum first to leverage Docker layer caching
COPY go.mod go.sum ./
# Download dependencies before copying source code
RUN go mod download
# Copy the rest of the application source code
COPY . .
# Compile the binary with CGO disabled for a fully static build
RUN CGO_ENABLED=0 go build -o main .

# Switch to a minimal runtime image to reduce attack surface
FROM alpine:latest
WORKDIR /app
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/main .
# Expose the port the API will listen on
EXPOSE 8080
# Run the binary as the entrypoint
CMD ["./main"]

Here is the compose file that starts the API and a PostgreSQL container on a shared network:

# Define the services that make up the application stack
services:
  api:
    build: .
    # Map host port 8080 to container port 8080
    ports:
      - "8080:8080"
    # Declare that the db service must start before api
    depends_on:
      - db
    # Pass the database connection string as an environment variable
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
  db:
    image: postgres:16
    # Set credentials and default database for the PostgreSQL image
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb

Run docker compose up --build to compile the image and start both containers. The API binds to port 8080 on your host. The database listens internally on port 5432. The containers communicate over a private Docker network where db resolves to the database container's IP address.

Keep the baseline simple. Complexity hides bugs.

How the build actually runs

Docker reads the Dockerfile from top to bottom. Each instruction creates a new layer in the image cache. The COPY go.mod go.sum ./ line triggers a dependency download that Docker caches. If you change a .go file later, Docker skips the RUN go mod download step because the module files did not change. That saves minutes on repeated builds.

The CGO_ENABLED=0 flag tells the Go compiler to avoid C dependencies. The resulting binary links everything statically. It contains no shared libraries. That is why you can drop it into a bare Alpine image and run it without installing libc or glibc. Go programs that rely on CGO, such as those using certain database drivers or cryptographic libraries, will fail at runtime with exec format error or missing shared library messages if you force static linking incorrectly. Stick to pure Go drivers when you want zero-runtime dependencies.

Docker Compose creates a default bridge network named after your project directory. It assigns each service a DNS name matching the service key. Your Go code connects to db:5432 because Docker's embedded DNS resolver maps the hostname to the container's internal IP. The depends_on key only controls startup order. It does not wait for the database to accept connections. Your application must handle transient connection failures during boot.

Build layers are cached. Network names are deterministic. Static binaries are portable. Trust the pipeline.

Production-ready orchestration

The minimal setup works for development. Production requires health checks, non-root execution, explicit resource limits, and graceful shutdown handling. You also need to separate build artifacts from runtime configuration.

Here is a hardened Dockerfile that adds a dedicated user, strips debug symbols, and sets up proper signal handling:

# Build stage: compile the static binary
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Disable CGO and strip debug info to shrink the binary
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o main .

# Runtime stage: minimal image with explicit user
FROM alpine:latest
# Create a non-root user to reduce privilege escalation risk
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/main .
# Change ownership so the non-root user can read the binary
RUN chown appuser:appgroup /app/main
# Switch to the non-root user before running
USER appuser
EXPOSE 8080
# Use exec form so signals reach the Go process directly
CMD ["./main"]

Here is a compose file that adds health checks, restart policies, and volume persistence for the database:

services:
  api:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
    # Restart unless manually stopped
    restart: unless-stopped
    # Check the /healthz endpoint every 10 seconds
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
      interval: 10s
      timeout: 5s
      retries: 3
  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    # Persist data across container recreation
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      # Verify the database accepts connections before marking healthy
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      timeout: 3s
      retries: 5
volumes:
  pgdata:

The condition: service_healthy directive tells Compose to wait until the database reports success before starting the API. The CMD ["./main"] exec form ensures that SIGTERM reaches the Go process directly. Go's os/signal package can catch that signal and close database connections gracefully. If you wrap the command in a shell like CMD ["sh", "-c", "./main"], the shell intercepts the signal and your goroutines hang until the container times out.

Health checks prevent traffic from hitting half-initialized services. Non-root users limit blast radius. Exec form preserves signals. Ship it.

Where things break

Containerized Go applications fail in predictable ways. Most failures stem from environment assumptions rather than code bugs.

The most common runtime error is dial tcp db:5432: connect: connection refused. This happens when the API starts before PostgreSQL finishes initializing, or when the hostname in DATABASE_URL does not match the Compose service name. The fix is to use condition: service_healthy and implement a retry loop in your Go code that backs off exponentially until the connection succeeds.

Another frequent issue is permission denied when writing logs or uploading files. Containers run as root by default, but production images should drop privileges. If your Go program tries to write to /tmp or a mounted volume without the correct ownership, the kernel blocks the operation. Set explicit USER directives and verify file permissions during the build stage.

Architecture mismatches cause exec format error. If you build the image on an Apple Silicon Mac and push it to a Linux x86 server without cross-compilation, the binary will not run. Docker Buildx solves this by compiling for multiple platforms in parallel. You do not need to run a separate build server.

Environment variables that contain special characters break shell parsing. If you pass a password with a $ or ! through a .env file, Compose may interpolate it incorrectly. Quote values in the YAML or use Docker secrets for sensitive data. Never bake credentials into the image layers.

Connection timeouts during graceful shutdown usually mean your HTTP server is not listening for SIGTERM. Go's http.Server supports Shutdown(ctx). Wire it to a signal handler, wait for active requests to finish, and close the database pool. The worst goroutine bug is the one that never logs.

Choosing your deployment path

Pick the right packaging strategy for your workflow. Each approach trades simplicity for control.

Use a single-stage Dockerfile when you are prototyping and want the fastest feedback loop. Use a multi-stage build when you ship to staging or production and need minimal image size. Use docker compose for local development and small team deployments where you control the host. Use a container registry with multi-arch manifests when your infrastructure spans different CPU architectures. Use Kubernetes or a managed service when you need automatic scaling, rolling updates, and centralized logging. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next