Chapter 1: Docker foundations
Every example in this handbook runs in Docker. You might expect containers to add complexity. In practice they remove a whole class of bugs that only appear when code moves between machines - the “works on my machine” bugs that waste days and erode trust in a deployment pipeline.
This chapter covers what you need to know about Docker to ship a real product: building efficient images, running containers with resource limits, managing volumes that survive deploys, connecting services over networks, and structuring multi-service stacks with Compose. It is not a Docker tutorial - it is a production Docker setup, trimmed to what a solo operator actually uses.
Learning objectives
After this chapter you will be able to:
- Build efficient multi-stage Docker images for Go and Node services
- Run your full application stack with docker compose
- Configure persistent volumes for databases, uploads, and generated assets
- Connect services over Docker networks with internal DNS
- Set memory and CPU limits that keep a VPS alive under load
- Forward container logs to a central place for debugging
Why this matters
You have heard the enterprise arguments for containers: reproducible
builds, clean deploys, isolation. Those are real. But the small-team
argument is simpler: every machine shares the same toolchain.
When you set up a new laptop, or a CI runner, or a VPS, you run
docker compose up and the entire stack boots with exactly
the versions you tested. No “works on my machine” debugging ever. The
service containers - web, database, cache - run the same images from
localhost to production.
Without the foundation this chapter builds, you will burn time on toolchain issues during every later chapter. With it, the VPS setup in Chapter 2, the TLS termination in Chapter 3, and the CI/CD pipeline in Chapter 4 all slot into a model you already understand.
Docker concepts and vocabulary
Docker vocabulary is small but precise. Mixing terms causes confusion when reading logs, Compose files, or Dokploy documentation.
| Concept | What it is | You will use it for |
|---|---|---|
| Image | Immutable filesystem snapshot + default command | Built from Dockerfile; pushed to a registry for deploy |
| Container | Running (or stopped) instance of an image | Your app while docker compose up is active |
| Volume | Persistent storage managed by Docker | SQLite database files, file uploads, generated assets |
| Bind mount | Host directory mapped into container path | Live-reload source code during development |
| Compose | Multi-container orchestration from
docker-compose.yml |
App + database + reverse proxy, env files, port maps |
| Registry | Image storage (Docker Hub, GHCR) | Pull base images; push your app image for CI/CD |
Think of an image as a recipe and a container as the meal you just cooked. Delete the container; the image remains for the next run. Change the Dockerfile; rebuild the image before the next deploy. A volume is the pantry - it persists across meals.
Building your first image
A Dockerfile for a Go service is short. Here is a multi-stage build that produces a small final image:
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/server ./cmd/server
FROM alpine:3.24
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/server /bin/server
EXPOSE 8080
CMD ["/bin/server"]The build stage pulls Go 1.26, downloads dependencies (cached until
go.mod changes), and compiles a statically linked binary.
The -s -w linker flags strip debug symbols - the binary is
smaller and faster to start. The final stage is a clean Alpine image
with only the binary and TLS certificates. The build toolchain never
enters the production image.
For a Node/React frontend, the pattern is similar but splits build output into an nginx runtime image:
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json .
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:stable-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]The build stage runs npm ci (faster and deterministic vs
npm install) and produces static assets in
/app/dist. The final nginx stage copies only those assets
and a config file.
Layer caching
Images stack layers - each Dockerfile instruction creates one. Layers cache: if you change only application source code, rebuilding reuses the cached base image and dependency layers. This matters when images include full Go or Node toolchains - cold builds take minutes; incremental builds take seconds.
FROM alpine:3.24 ← cached unless you change the tag
RUN apk add ... ← cached unless you change packages
COPY --from=builder ← only changes when the binary changes
CMD ["/bin/server"] ← always cached (no filesystem change)
Order instructions from least-changing to most-changing.
COPY . . should come after dependency downloads. If you
copy all source before go mod download, every code edit
invalidates the dependency cache and forces a full re-download.
Volumes that survive deploys
Containers are ephemeral by design. When you push a new image, the old container is replaced. Any data stored inside the container filesystem is lost. Volumes solve this by persisting data outside the container lifecycle.
Named volumes vs bind mounts
| Type | Managed by | Best for | Example |
|---|---|---|---|
| Named volume | Docker | Database files, uploads, anything you do not edit by hand | postgres_data volume for PostgreSQL |
| Bind mount | You (host path) | Live-reload source code during dev; config files | ./src:/app/src:ro |
In a Compose file, volumes look like this:
services:
app:
image: myapp:latest
volumes:
- app_data:/data # named volume
- ./uploads:/uploads:rw # bind mount (host path)
volumes:
app_data:The named volume app_data persists across
docker compose down (unless you add -v). The
bind mount ./uploads maps a host directory into the
container - useful for local development when you want edits to appear
instantly.
For a database container, mount a volume to the data directory. For uploaded files, mount a volume to the upload path or use an object storage service. Never store state inside a container that you cannot afford to lose.
Warning:
docker compose down -vdeletes named volumes. Use intentionally - this wipes your database. Document backup steps before running it in production.
Docker networking
Compose services on the same project share a default network. The service name is the DNS hostname within that network:
services:
api:
image: myapp:latest
environment:
DATABASE_URL: postgres://appuser:apppass@db:5432/appdb
ports:
- "8080:8080"
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/dataFrom inside the api container, hostname db
resolves to the database container’s internal IP. The
ports: directive is for your browser on the host - not for
container-to-container communication.
Bridge networks
For more complex setups, create explicit networks to isolate services:
networks:
frontend:
backend:
services:
web:
networks:
- frontend
api:
networks:
- frontend
- backend
db:
networks:
- backendThe api service can reach both web and
db. But web cannot reach db
directly - it must go through the API. Network isolation reduces the
blast radius when a public-facing service is compromised.
Connecting existing containers
If you need to debug by connecting a temporary container to an existing network:
docker run --rm -it --network myapp_default alpine:3.24 sh
# Inside: nslookup db, curl http://api:8080/healthThis is the operator’s Swiss Army knife - it gives you an interactive shell inside the network without modifying any running containers.
Multi-service docker compose files
A production Compose file for a typical application has three to four services:
services:
[traefik](https://traefik.io):
image: traefik:v3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- traefik_certs:/letsencrypt
app:
image: ghcr.io/you/yourapp:latest
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
PORT: "8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.app.tls=true"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:
traefik_certs:Key patterns in this file:
depends_onwithcondition: service_healthy- the app waits for PostgreSQL to pass its health check before starting. Without the health check condition, the app might start before the database is ready to accept connections.restart: unless-stopped- the container restarts if it crashes but not if you deliberately stop it withdocker compose stop.${DB_PASSWORD}- Compose reads variables from the host environment or a.envfile. Never hardcode secrets in compose files committed to git.- Traefik labels - configure routing and TLS without editing Traefik config files. Chapter 3 covers Traefik in depth.
Environment variable injection
Containers read configuration from the environment. There are multiple injection points, and understanding which wins is critical:
| Method | Scope | Use case |
|---|---|---|
docker-compose.yml environment: |
Per service | Hardcoded dev defaults, override env_file values |
docker-compose.yml env_file: |
Per service | Load .env file during development |
.env file in project root |
All services in compose | Shared variables or defaults |
docker run -e KEY=val |
Single container | Ad-hoc overrides for debugging |
Shell environment (export KEY=val) |
Compose variable substitution | CI/CD pipeline injected values |
Precedence: environment: in compose overrides
env_file:, which overrides the shell environment.
Production rule: Environment variables in the
Dockerfile (ENV) are visible in docker history
- anyone who pulls the image can see them. Never set secrets with
ENV. Use Dokploy’s environment UI (Chapter 2) or Docker
secrets for production. For local development, .env files
work if gitignored.
Health checks
A health check tells Docker (and orchestrators like Traefik) whether a container is healthy enough to receive traffic. Without one, a container that starts but hangs silently gets traffic and returns errors.
HTTP health check (Go service)
In your Dockerfile or Compose file:
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15sYour application needs a /health endpoint that returns
200:
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})Database health check (PostgreSQL)
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30sstart_period gives the container time to initialize
before health checks begin counting failures. PostgreSQL needs this -
the first startup initializes the data directory and can take tens of
seconds.
Health check dependencies
Use depends_on with health conditions to control startup
order:
app:
depends_on:
db:
condition: service_healthy
redis:
condition: service_startedservice_healthy waits for the health check to pass.
service_started waits only for the container to start -
useful for services without health checks.
Logging drivers
By default, Docker captures container stdout/stderr and stores it in JSON files on disk. For production, configure a logging driver that forwards logs somewhere searchable.
json-file (default)
Good for development. Logs are accessible with
docker compose logs. On a VPS, these files grow unbounded
unless you set limits:
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"This rotates logs at 10 MB and keeps the last 3 files - roughly 30 MB per service.
journald
Forward to systemd’s journal (available on most Linux VPSs):
services:
app:
logging:
driver: journald
options:
tag: "{{.Name}}"Read logs with journalctl CONTAINER_NAME=app. Useful
when you already use journald for system logs and want a single query
interface.
loki or elasticsearch
For multi-server setups or when you need full-text search across logs, use the Loki or Elasticsearch drivers. At solo scale, json-file with size limits is sufficient - Loki adds operational complexity you probably do not need before you have multiple servers.
Resource limits
A container without limits can consume all available memory on a machine. When the same VPS runs your web service and database side by side, an unbounded process can freeze both. Set limits in your compose file:
services:
app:
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
reservations:
cpus: "0.25"
memory: 128M
db:
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
cpus: "0.5"
memory: 256M| Field | Meaning |
|---|---|
limits.memory |
Hard cap - container is OOM-killed if it exceeds this |
limits.cpus |
Maximum CPU share (1.0 = one full core) |
reservations.memory |
Guaranteed minimum; scheduler uses this for placement |
reservations.cpus |
Guaranteed CPU share |
For a 2 vCPU / 4 GB VPS running three services (app, database, Traefik), allocate roughly:
- App: 256 MB memory, 0.75 CPU
- Database: 512 MB memory, 1.0 CPU
- Traefik: 128 MB memory, 0.25 CPU
This leaves the OS ~3.1 GB for file cache, swap, and headroom. Adjust
based on actual usage - docker stats shows live
consumption.
Worked example: web + database stack
Build a docker-compose.yml for a Go API with PostgreSQL.
This is the pattern you will use in every later chapter.
Project structure
myapp/
├── cmd/server/main.go
├── internal/
│ ├── handlers/
│ └── store/
├── Dockerfile
├── docker-compose.yml
├── .env
├── .env.example
└── .gitignore
docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb?sslmode=disable
PORT: "8080"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
db_data:.env.example (committed)
DB_PASSWORD=change-me-in-production
STRIPE_SECRET_KEY=sk_test_REPLACE_ME
.env (gitignored)
DB_PASSWORD=devpassword123
STRIPE_SECRET_KEY=sk_test_abc123
Running the stack
docker compose up -d --build
docker compose logs -f app
curl http://localhost:8080/healthCheckpoint exercise: web + database stack
Write a docker-compose.yml for a simple web application
backed by a PostgreSQL database. Use the worked example above as a
template but adjust for your project:
- Create a project directory with a
Dockerfile(use the Go multi-stage example or adapt for your language). - Write a
docker-compose.ymlwith two services:appanddb. - Add health checks on both services so
depends_oncan wait for the database. - Set memory limits: 256 MB for app, 512 MB for database.
- Add log rotation: 10 MB per file, max 3 files.
- Create
.env.exampleand.env(gitignored). Use${DB_PASSWORD}variable substitution. - Run
docker compose up -dand confirmcurl http://localhost:8080/healthreturns 200. - Run
docker stats --no-streamand confirm memory is within limits.
Key takeaways
- Multi-stage Docker builds decouple build toolchains from production images - the Go compiler and npm never ship to production
- Volumes are the only persistent storage in Docker; container filesystems are replaced on every redeploy
- Compose service names are internal DNS hostnames -
dbresolves inside the network, notlocalhost - Memory and CPU limits prevent one misbehaving service from freezing the entire VPS
- Health checks turn
depends_onfrom a race condition into a reliable startup sequence - Log rotation (
max-size,max-file) prevents log files from filling your VPS disk
Common pitfalls
- Orphan containers. Running
docker compose upfrom a different directory or with a different project name creates a separate set of containers. Old containers sit idle consuming resources. Usedocker compose psto audit anddocker container pruneto clean up. - Port conflicts. Only one process can bind a host
port at a time. If port 8080 is in use, change the left side of the
mapping (
"8081:8080"). Uselsof -i :8080on macOS/Linux to find the culprit. - Disk space exhaustion. Docker images, stopped
containers, and build cache accumulate. Run
docker system prune -amonthly on your VPS (but not on a production database volume). On a small VPS, a full disk can crash every service. - Committing
.envfiles. TheENVinstruction in a Dockerfile embeds values visible indocker history. Use.envonly for local dev; inject secrets at runtime via Compose environment blocks or Dokploy (Chapter 2). - Missing
start_periodon databases. PostgreSQL needs time to initialize the data directory on first start. Without astart_period, the health check may fail during initialization anddepends_onmay never signal readiness. - Binding to 127.0.0.1 inside a container. Containers
need to listen on
0.0.0.0to accept connections from the Docker network and Traefik. Serve on:8080not127.0.0.1:8080.
Further reading
- Docker overview - the container model used in every example
- Docker Compose file reference - every configuration key explained
- Multi-stage builds - the pattern that keeps production images small
- Docker networking - bridge, overlay, and host networks
- Docker logging drivers - all supported drivers and configuration