Docker in Production: Running Web Apps Without the Headaches

A practical guide to running web apps in Docker — building small, immutable images, wiring health checks, keeping secrets out of the image, and knowing what not to containerize.

Docker in Production: Running Web Apps Without the Headaches

Docker makes local development easy — one docker compose up and the whole stack is running. But production is where containers either earn their keep or burn you. The difference isn't the tool, it's the discipline. Here's how we run web apps in Docker on real servers, without the horror stories.

Start from a small, official base

The base image you choose decides your attack surface, disk usage, and pull time. Start from official images (php:8.4-fpm, node:24-alpine, golang:1.26), not random community builds, and prefer slim variants. If your final image needs a compiler, use a multi-stage build so the toolchain never ships:

# Stage 1: build with everything
FROM golang:1.26 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

# Stage 2: runtime with nothing extra
FROM scratch
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The result is a binary-only image measured in megabytes, with no shell an attacker could use.

Immutable images, mutable data

Containers are cattle, not pets — but the rule only holds if nothing important lives inside them. Treat the container filesystem as disposable: write logs to stdout (the container runtime collects them), and keep databases, uploads, and user-generated files in named volumes or, better, a managed database outside Docker entirely. A container should die and be replaced without anyone noticing; if it holds state, it can't.

Health checks make the process observable

A container running is not a container working. Define a health check that exercises the real endpoint, so the orchestrator knows the difference:

services:
  app:
    image: myapp:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3

That /healthz endpoint should verify the app can actually do its job — database reachable, cache connected — not just return 200 from a middleware that short-circuits everything.

Secrets never go into images

The most common production Docker mistake we see: ENV DB_PASSWORD=... baked into the image or committed in a docker-compose.yml. Anyone with the image has the password. Inject secrets at runtime — from the host environment, a secrets manager, or an env file that never enters git:

services:
  app:
    image: myapp:latest
    env_file:
      - .env.production   # in .gitignore, present only on the server

Build once, run with different config everywhere. An image without secrets is an image you can safely share, cache, and roll back.

Know what not to containerize

Docker is a great fit for web workers, background job queues, and API services. It's a poor fit for the database itself in most small-to-medium setups — the state problem above comes back to haunt you. Our default: containerize the application, let a managed or dedicated database live outside. Backup, performance tuning, and failover suddenly stop being container problems.

Build once, deploy the same artifact

Tag images with the commit hash or version, push them to a registry, and let the server pull exactly what CI tested. If the image that runs in production is byte-identical to the one that passed tests, most "works on my machine" bugs disappear by construction. Combined with rolling restarts — start the new container, wait for its health check to pass, stop the old one — you get zero-downtime deploys without Kubernetes-level complexity.

The payoff

Docker in production isn't about being trendy — it's about reproducibility. The same image runs on your laptop, the CI runner, and the server; rollbacks become a one-command pull of the previous tag; a new VPS is provisioned in minutes, not a weekend of setup. The discipline is small: minimal images, stateless containers, real health checks, secrets out of the image. Containerize the right things and deploys become boring — which is exactly the point.