Docker In The Real World: What We Actually Run

docker

Docker In The Real World: What We Actually Run

Practical patterns, small gotchas, and calmer nights on-call.

Why Docker Still Earns Its Seat At The Table

We’ve all watched tools come and go, and we’ve all got the scars from “works on my laptop” turning into “doesn’t work in prod, please don’t page me again.” Docker stuck around because it solves an annoyingly human problem: consistency. When we package an app with its runtime, libraries, and basic filesystem expectations, we stop arguing about whose machine is “the reference.” The container becomes the reference.

In the real world, we don’t use Docker to feel modern—we use it because it makes change less dramatic. New engineer joins? “Run this.” CI needs the same setup as staging? “Use the same image.” Security wants a clear bill of materials? We can point to a tagged image and the Dockerfile that made it. It’s not magic, but it’s tangible.

Docker also gives us a crisp contract between teams. App folks can define what they need (ports, env vars, volumes), and platform folks can define how it runs (resources, networks, rollout rules). That separation is the difference between “everyone touches everything” and “we can ship without stepping on each other’s toes.”

A quick reality check: Docker isn’t the whole platform. It’s a packaging and runtime layer. You’ll still need logging, monitoring, secrets, backups, and a plan for upgrades. But as the unit of delivery, a container image is hard to beat.

If you want the official grounding, start with the upstream docs: Docker overview. Then come back—we’ll stick to what we actually do day-to-day.

A Dockerfile That Won’t Embarrass Us Later

Most Dockerfiles start life as “good enough” and end life as “why is this 2GB and slow.” Our goal is boring: small images, repeatable builds, and fewer security surprises. The easiest win is multi-stage builds. We compile/build in one stage, run in a slimmer stage, and leave the messy toolchain behind like yesterday’s coffee cup.

Here’s a pattern we use a lot for a Node service:

# syntax=docker/dockerfile:1

FROM node:20-bookworm AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app

# Only what we need to run:
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

A few “don’t regret it later” notes:
Pin a major version at minimum. Floating “latest” is how we schedule surprise outages.
Use npm ci for deterministic dependency installs.
Run as non-root unless you have a specific reason not to.
Copy only what you need into the runtime image. Less stuff means fewer vulnerabilities and faster pulls.

For extra polish, use a .dockerignore so you don’t accidentally send your entire repo history to the daemon. Docker’s build docs are worth a skim: Dockerfile best practices.

And yes, someone will eventually ask why builds aren’t cached anymore. Usually it’s because we copy the whole repo too early. Keep dependency manifests separate from source copy to preserve caching.

Docker Compose: Local Environments That Don’t Hate Us

Compose is the unsung hero of “make local dev not painful.” It’s not just for hobby projects—we use it to spin up realistic stacks quickly: app + database + cache + queue + a local S3 emulator if we’re feeling fancy. The key is to keep Compose files readable and production-adjacent without pretending Compose is production.

A solid baseline might look like this:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      - db
      - cache

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  cache:
    image: redis:7
    ports:
      - "6379:6379"

volumes:
  pgdata:

What we like about this:
– Local dev gets real Postgres and Redis, not mocks that drift from reality.
volumes keep state between restarts without polluting the host filesystem.
depends_on expresses startup order (not readiness—still important to handle retries).

A couple of practical Compose habits:
– Keep secrets out of the file. Use .env for local-only values and a real secrets manager elsewhere.
– Name containers only when needed—hardcoding names can cause collisions.
– Use profiles for optional services (e.g., “with-observability”), so not everyone runs everything.

Compose has great docs and fewer surprises when you follow them: Docker Compose overview. And if someone says “it worked yesterday,” check whether they blew away volumes. It’s always the volumes.

Images, Tags, And Registries: Discipline Beats Heroics

If Docker had a leading cause of midnight pages, it’d be “someone pulled the wrong tag.” Tags feel casual, but they’re how we decide what code runs. Our rule: tags should be meaningful, immutable, and traceable.

A pattern we use:
app:gitsha for exactness (e.g., app:3f2c1d7)
app:1.8.4 for release versions
app:stable only if we control who moves it and when

We also like adding labels so anyone can answer “what is this?” without guessing:

  • org.opencontainers.image.source
  • org.opencontainers.image.revision
  • org.opencontainers.image.created

OCI labels are well documented and widely supported: OpenContainers Image Spec.

On registries: keep it simple. Whether it’s Docker Hub, GHCR, ECR, GCR, or ACR, the same basics apply:
– Lock down write access; most people only need pull.
– Enable retention policies so you don’t pay to store history from 2019.
– Sign images if your compliance story requires it (or if your security team is already sharpening knives).

Vulnerability scanning is useful, but don’t treat it like a moral score. Fix what matters: internet-facing services, privileged containers, and core libraries. Then work down the list. We’re aiming for “safer than yesterday,” not perfection on a dashboard.

Networking And Volumes: Where “Simple” Gets Interesting

Networking is where Docker is either delightfully easy or deeply confusing, sometimes within the same five minutes. The good news: most teams only need the default bridge network and service-to-service DNS (especially with Compose). Containers can talk by service name, and we can publish only the ports we intend to expose.

A few guardrails we use:
Expose internally, publish selectively. EXPOSE is documentation; -p is what opens the door.
– Prefer user-defined networks over legacy defaults when you need clarity and isolation.
– Don’t publish databases to the host unless you must. If you do, do it intentionally and document why.

Volumes are the other “here be dragons” area, mostly because state is sticky and people forget. Named volumes are great for local dev and simple deployments, but we also make sure we can rebuild from scratch without losing critical data (because at some point someone will run docker system prune like it’s spring cleaning).

We keep a mental model:
Bind mounts: great for local source code, but leaky and OS-dependent.
Named volumes: portable Docker-managed persistence.
Tmpfs: fast, ephemeral, good for sensitive scratch data.

If you’re debugging “it can’t connect,” do the boring checks first: is the container on the same network, is the port correct, are you using localhost inside a container (which points to itself), and are you accidentally publishing on IPv6 only? Yes, we’ve done that. No, we don’t talk about it at parties.

Logs, Healthchecks, And The Art Of Not Guessing

If we can’t see what a container is doing, we’re basically doing tarot readings. Docker makes it easy to stream logs, but we need a consistent approach so incidents don’t turn into archaeology.

Our basics:
– Write logs to stdout/stderr. Don’t hide them in /var/log inside the container unless you’re deliberately managing that path.
– Use structured logs if possible (JSON). Even “k=v” is better than interpretive poetry.
– Add healthchecks so orchestration can make smarter decisions than “it’s running, probably.”

A practical healthcheck in a Dockerfile:

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

Or in Compose:

healthcheck:
  test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
  interval: 30s
  timeout: 3s
  retries: 3
  start_period: 20s

Then we wire logs into a central system (ELK/OpenSearch, Loki, whatever fits). The specific stack matters less than the habit: don’t debug by SSH’ing into containers as your default move. You can, but it shouldn’t be the plan.

Docker’s logging drivers are worth knowing about when you scale beyond “just tail it”: Configure logging drivers. Also: if your app doesn’t have /health, add one. Future us will send present us a thank-you note.

Security And Upgrades: Boring Work That Saves Weekends

Container security doesn’t need theatrics. It needs a checklist and the will to keep doing it. Our “boring but effective” approach:

  • Base images: use official images or well-known minimal bases. Keep them updated.
  • Run as non-root: don’t give the process more power than it needs.
  • Read-only filesystems: when feasible, mount writable paths explicitly.
  • Drop capabilities: if you’re on Docker run, use --cap-drop=ALL and add back only what’s required.
  • Secrets: don’t bake credentials into images; don’t pass them in args; prefer mounted secrets or env vars managed by your platform.
  • Scan and patch: schedule rebuilds, don’t wait for an incident.

Upgrades are where teams get stuck. The trick is to make rebuilding normal. If we rebuild weekly, base image CVEs get handled as routine maintenance instead of panic. If we rebuild once a quarter, everything breaks at once and the on-call rotation starts bargaining with the universe.

Also, don’t ignore the Docker Engine itself. Keep hosts updated, and understand what version you’re running. Compatibility issues are rare until they’re very not rare.

For a sensible baseline, Docker publishes hardening guidance: Docker security. Pair that with least-privilege defaults and you’ll avoid most self-inflicted wounds. The goal isn’t “perfectly secure.” The goal is “difficult to mess up by accident.”

Share