DEV Community

Cover image for Docker Images I'd Actually Ship: A Production Checklist
Parsa Aftabi
Parsa Aftabi

Posted on

Docker Images I'd Actually Ship: A Production Checklist

Most Dockerfiles I've seen in the wild are development artifacts wearing a production costume. Someone took the image they debugged with, slapped a CMD on it, and called it a deploy. It works — right up until it doesn't: 1.2GB images, root processes, secrets baked into layers, and deploys that take ten minutes to pull.

Building a production image is a different discipline. Here's my checklist, the stuff I actually do on every service I ship, PHP or Go.

Multi-stage builds are the whole game

If you take one thing from this article, take this. A builder stage with your full toolchain, a runtime stage with almost nothing:

# ---- builder ----
FROM php:8.3-cli AS builder
RUN apt-get update && apt-get install -y git unzip \
    && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . .
RUN composer dump-autoload --optimize

# ---- runtime ----
FROM php:8.3-fpm
WORKDIR /var/www
COPY --from=builder /app /var/www
# ... config, permissions, user
Enter fullscreen mode Exit fullscreen mode

Composer, git, build tools — none of that belongs in the image your users' requests hit. The runtime stage should contain your code, your runtime, and nothing else. This one change usually cuts image size by more than half.

Pick your base image like an adult

Three options, three personalities:

  • Alpine: tiny, fast pulls. But musl instead of glibc, which means the occasional weird DNS or binary-compat surprise at 2am. Fine for Go. I'd think twice for PHP with compiled extensions.
  • Debian slim: boring in the best way. glibc, everything behaves like your dev machine. Slightly bigger. This is my default for PHP.
  • Distroless / scratch: no shell, no package manager, no attack surface to speak of. Perfect for a statically compiled Go binary. Terrifying to debug — which is the point, because there's nothing to debug in.

My defaults: php:8.3-fpm (debian-based) for PHP, gcr.io/distroless/static for Go. Boring choices that don't page me.

Order your layers for the cache

Docker caches layers top-down, and every COPY invalidates everything below it. So: dependencies first (they change rarely), source code last (it changes constantly).

COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
Enter fullscreen mode Exit fullscreen mode

go mod download on its own layer means a code change doesn't re-download the world. Same idea with composer.json/composer.lock before the full source copy. Get this wrong and every deploy rebuilds everything from scratch while your CI bill quietly grows.

Run as non-root. No exceptions.

A container running as root that escapes is a host compromise. A container running as nobody that escapes is a bad day. The fix is one line and some permission hygiene:

RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
USER www-data
Enter fullscreen mode Exit fullscreen mode

Do it after everything that needs root (installs, chowns), as late as possible in the Dockerfile. If your app can't run without root, that's a bug in your app, not a reason to skip this.

.dockerignore — the file everyone forgets

Without it, your build context includes .git, vendor/, node_modules/, your designer's 400MB of PSDs, and last year's backup. Slow builds, bloated contexts, and occasionally secrets you didn't mean to ship.

.git
vendor/
node_modules/
.env
*.md
docker-compose*.yml
Enter fullscreen mode Exit fullscreen mode

Five minutes, once. There's no excuse.

Healthchecks that actually check something

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:8080/health || exit 1
Enter fullscreen mode Exit fullscreen mode

"Port is open" is not a health check. Your /health endpoint should verify what the app actually needs: can it reach the database, is Redis responding, is the disk writable. A container that accepts traffic while its DB connection is dead is worse than a container that's down — at least a down container gets restarted.

Secrets don't go in images. Ever.

Build args, ENV lines with passwords, .env files copied into the image — all of these end up in layer history, readable by anyone who can pull the image. I've seen production AWS keys in public images. Don't be that team.

Secrets arrive at runtime: environment variables from your orchestrator, mounted secret files, a vault agent. The image should boot with zero credentials and complain loudly if they're missing. Fail fast on missing config is a feature.

PHP specifics

  • Leave xdebug at home. It's a dev extension. It slows every request and has no business in production. --no-dev on composer, no dev extensions in the runtime stage.
  • Tune OPcache in the image, not on the server. opcache.enable=1, opcache.validate_timestamps=0 (you redeploy instead of editing files on the box, right?), generous opcache.memory_consumption.
  • One process per container. PHP-FPM in one container, nginx in another. Or skip the pair entirely — FrankenPHP serves a Laravel app from a single image with no FPM/nginx split, and it's genuinely simpler to operate.

Go specifics

This is where Docker shines brightest:

FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Enter fullscreen mode Exit fullscreen mode

A static binary, no libc, no shell, running as nonroot. The final image is ~15MB. Fifteen. Compare that to the 1GB PHP-FPM image you started with and tell me Go doesn't earn its reputation.

The numbers

The pattern repeats everywhere: single-stage images with dev dependencies and no .dockerignore routinely land in the 1GB+ range. Rebuild the same app multi-stage — slim runtime, no dev deps, non-root, tuned OPcache — and it lands an order of magnitude smaller, typically well under 200MB. Pull time goes from minutes to seconds, which matters a lot when you're scaling up under load.

The checklist

Multi-stage build. Boring base image. Deps before source in layer order. Non-root user. A real .dockerignore. Healthchecks that check dependencies. Secrets only at runtime. No dev tools in the runtime stage.

None of this is exotic. That's the point — production Docker isn't about clever tricks, it's about doing the boring things consistently. The teams that get paged at 3am are usually the ones that skipped the boring things.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.