Deploy & Ship preview

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 -v deletes 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/data

From 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:
      - backend

The 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/health

This 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_on with condition: 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 with docker compose stop.
  • ${DB_PASSWORD} - Compose reads variables from the host environment or a .env file. 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: 15s

Your 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: 30s

start_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_started

service_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/health

Checkpoint 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:

  1. Create a project directory with a Dockerfile (use the Go multi-stage example or adapt for your language).
  2. Write a docker-compose.yml with two services: app and db.
  3. Add health checks on both services so depends_on can wait for the database.
  4. Set memory limits: 256 MB for app, 512 MB for database.
  5. Add log rotation: 10 MB per file, max 3 files.
  6. Create .env.example and .env (gitignored). Use ${DB_PASSWORD} variable substitution.
  7. Run docker compose up -d and confirm curl http://localhost:8080/health returns 200.
  8. Run docker stats --no-stream and 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 - db resolves inside the network, not localhost
  • Memory and CPU limits prevent one misbehaving service from freezing the entire VPS
  • Health checks turn depends_on from 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 up from a different directory or with a different project name creates a separate set of containers. Old containers sit idle consuming resources. Use docker compose ps to audit and docker container prune to 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"). Use lsof -i :8080 on macOS/Linux to find the culprit.
  • Disk space exhaustion. Docker images, stopped containers, and build cache accumulate. Run docker system prune -a monthly on your VPS (but not on a production database volume). On a small VPS, a full disk can crash every service.
  • Committing .env files. The ENV instruction in a Dockerfile embeds values visible in docker history. Use .env only for local dev; inject secrets at runtime via Compose environment blocks or Dokploy (Chapter 2).
  • Missing start_period on databases. PostgreSQL needs time to initialize the data directory on first start. Without a start_period, the health check may fail during initialization and depends_on may never signal readiness.
  • Binding to 127.0.0.1 inside a container. Containers need to listen on 0.0.0.0 to accept connections from the Docker network and Traefik. Serve on :8080 not 127.0.0.1:8080.

Further reading