The local stack problem
You write a Go HTTP server. It compiles, it runs, it returns JSON. You decide to add a PostgreSQL database for persistence. Now you need to install Postgres locally, configure credentials, manage version mismatches, and hope your teammate's environment matches yours. The moment your application depends on anything beyond itself, local development turns into infrastructure management.
Docker Compose solves this by turning your entire stack into a single declarative file and a single command. You describe the services, the networks, the volumes, and the environment. Compose reads the description, builds the images, wires everything together, and starts the containers in the correct order. You get a reproducible environment that runs identically on your laptop, your teammate's machine, and your staging server.
How Compose actually works
Docker handles the containers. Compose handles the orchestration. The Docker runtime knows how to isolate processes, mount filesystems, and manage network namespaces. Compose sits on top of that runtime and translates a YAML file into a series of Docker API calls. It creates a dedicated network for your project, generates container names, maps ports, and manages the lifecycle of multiple services as a single unit.
Think of the Dockerfile as a blueprint for a single building. It tells you how to construct the structure, brick by brick. The Compose file is the city plan. It tells you where the building goes, which roads connect it to other buildings, where the water and power lines attach, and in what order the construction crews should arrive.
The tool has evolved. The original docker-compose binary was written in Python and distributed separately. Modern Docker Desktop and Docker Engine ship with the docker compose plugin, which is written in Go and integrated directly into the CLI. The syntax and behavior are identical. The community convention is to use the plugin syntax: docker compose up, not docker-compose up. Most editors and CI pipelines have already migrated.
The minimal setup
Start with the absolute basics. A single Go service, no database, just a compiled binary running in a container. You need two files: a Dockerfile to build the image, and a docker-compose.yml to run it.
Here is the simplest Dockerfile for a Go application:
# Use the official Go image with Alpine Linux for a small base
FROM golang:1.22-alpine AS builder
# Set the working directory inside the container
WORKDIR /src
# Copy dependency manifests first to leverage Docker layer caching
COPY go.mod go.sum ./
# Download dependencies into the build cache layer
RUN go mod download
# Copy the rest of the source code
COPY . .
# Compile the binary with debug info stripped for smaller size
RUN go build -ldflags="-s -w" -o /app/server .
# Start a fresh stage to keep the final image under 20MB
FROM alpine:3.19
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/server /server
# Run the binary when the container starts
CMD ["/server"]
Here is the matching Compose configuration:
# Compose file version 3 is the current standard
services:
app:
# Build the image using the Dockerfile in the current directory
build: .
# Map host port 8080 to container port 8080
ports:
- "8080:8080"
# Restart the container if it crashes unexpectedly
restart: unless-stopped
Run docker compose up --build from the project root. The --build flag forces Compose to rebuild the image before starting the container. Without it, Compose will reuse the cached image from the last run.
What happens under the hood
When you execute the command, Compose parses the YAML, validates the schema, and creates a project-scoped network named after your directory. It then invokes the Docker daemon to process the build: . directive. The daemon reads the Dockerfile and executes each instruction as a layer.
Layer caching is the reason dependency manifests are copied before source code. Docker compares the checksum of each layer to its cache. If go.mod and go.sum have not changed, Docker skips the RUN go mod download step entirely. If you only changed a .go file, the dependency download layer is reused, and only the compilation step runs. This turns a thirty-second build into a three-second build during active development.
Once the image is built, Compose creates a container from it. It attaches the container to the project network, binds the host port to the container port, and executes the CMD instruction. The Go binary starts, binds to 0.0.0.0:8080 inside the container, and accepts traffic. Compose streams the logs to your terminal. Press Ctrl+C to stop the container and detach from the logs. Run docker compose down to stop the container, remove the network, and clean up.
Build context matters. The build: . directive tells Docker to send the entire current directory to the daemon. If your project contains a massive node_modules folder, a .git directory, or a vendor cache, Docker will archive and upload all of it. Add a .dockerignore file to exclude unnecessary files. The build will be faster, and the final image will be cleaner.
A realistic multi-service stack
Real applications rarely run alone. Add a PostgreSQL database, environment variables, and a health check. This setup mirrors production closely enough that you can catch integration issues before deployment.
Here is the updated Compose file with two services:
services:
app:
build: .
ports:
- "8080:8080"
# Pass database credentials as environment variables
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app?sslmode=disable
# Wait for the database to be ready before starting the app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=app
# Persist data across container restarts
volumes:
- pgdata:/var/lib/postgresql/data
# Verify the database accepts connections before marking healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d app"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
The depends_on directive with condition: service_healthy is the key difference between a fragile setup and a reliable one. Without the health check, Compose starts the database container, immediately starts the Go application, and the Go app crashes with dial tcp 127.0.0.1:5432: connect: connection refused because Postgres is still initializing. The health check runs pg_isready every five seconds. Compose blocks the app service until the check passes.
The volumes section creates a named volume that Docker manages. Named volumes survive docker compose down and persist across rebuilds. If you mount a host directory instead, you risk permission mismatches between your OS user and the container's postgres user. Named volumes let Docker handle the ownership automatically.
Go applications are statically linked by default. The binary contains everything it needs to run. You do not need to copy the entire Go toolchain into the runtime container. The multi-stage Dockerfile isolates the compiler in the builder stage and copies only the final executable into a minimal Alpine image. This convention reduces attack surface, speeds up deployments, and keeps images under twenty megabytes.
Common pitfalls and how to avoid them
Mounting the source directory for hot reloading breaks the build cache. Developers often add volumes: - .:/src to the Compose file to avoid rebuilding the image after every change. This works for interpreted languages. It breaks Go builds because the container expects a compiled binary, not source code. If you want fast iteration, run go run . locally with air or reflex, or use docker compose watch with a build command that compiles on file change. Do not mount source code into a production-style Dockerfile.
Environment variables in Compose do not affect go build. Setting GOOS=linux and GOARCH=amd64 in the environment block changes nothing. Those variables are read by the go command during compilation, not by the container runtime. The Dockerfile already runs inside a Linux environment. The binary compiles for the host architecture by default. If you need cross-compilation, pass the variables to the build step: args: { GOOS: linux, GOARCH: arm64 }.
Port conflicts happen when another process already occupies the host port. The error bind: address already in use means something else is listening on 0.0.0.0:8080. Run lsof -i :8080 or netstat -tulpn to find the culprit. Change the host port in Compose to 8081:8080 if you need to keep the other service running.
Forgetting to run go mod tidy before building causes silent dependency drift. If your go.mod references a newer version than go.sum, the build will fail with verifying module checksum: mismatch. Run go mod tidy to synchronize the manifests. Add it to your pre-commit hook or CI pipeline. The compiler will reject the build if the checksums do not match, and you will save hours of debugging.
The worst container bug is the one that exits immediately. If your Go program crashes on startup, Compose will restart it according to the restart policy. You will see a loop of Exited (1) and Restarting. Check the logs with docker compose logs -f app. Most startup crashes come from missing environment variables, invalid configuration files, or binding to a port that requires root privileges. Bind to ports above 1024 to avoid permission errors.
When to reach for Compose
Use docker compose when you need a reproducible local environment with multiple services, databases, or message queues. Use raw docker run when you are testing a single image, debugging a specific container, or running a one-off command. Use plain go run when you are iterating on business logic and do not need container isolation or external dependencies. Use Kubernetes or a managed orchestrator when you need horizontal scaling, rolling updates, and production-grade self-healing across multiple nodes.
Compose is development infrastructure. It is not a deployment target. It handles local networking, volume mounting, and service ordering. It does not handle load balancing, secret rotation, or cluster scheduling. Build your application to run in a container, test it with Compose, and deploy it to a platform that matches your production requirements.