The Docker Workflow I Use on Every Project

Photo by Unsplash

Photo by Unsplash
After running Docker in production for several years—across VPS environments, Cloud Run, and bare-metal servers at Commsult Indonesia—I've converged on a single, reusable workflow that I drop into every new project on day one. It's not revolutionary; it's boring in the best possible way. This post documents exactly what that setup looks like, why each piece is there, and the mistakes I made before settling on it.
The usual objection is that Docker adds complexity to small projects. That's true if you reach for Kubernetes or Swarm on a side project. But a compose.yml with two services—app and database—adds maybe 30 minutes of setup and saves hours of 'it works on my machine' debugging over the project's lifetime. The bigger win is parity: your local environment, your CI pipeline, and your production server all run the same image. When something breaks in CI, you can reproduce it locally in seconds instead of guessing at environment differences.
I use Docker not because containers are cool but because environment parity is genuinely hard to achieve otherwise. Node version managers (nvm, fnm) help with the runtime, but they don't solve OS library differences, locale settings, or the exact Postgres version your queries depend on. A Dockerfile locks all of that down. The compose.yml adds the surrounding services—database, cache, maybe a mail catcher—so the entire stack spins up with a single command.
Every project gets a Dockerfile at the root with three stages: base (shared Node image and package install), development (mounts source code, runs the dev server with hot reload), and production (copies only the compiled output, sets a non-root user, runs the minimal runtime). The compose.yml references the development target by default. CI and the production Dockerfile target the production stage. This means the same file serves all environments with no duplication.
┌─────────────────────────────────────────────────────────────┐
│ Docker Dev Workflow │
│ │
│ ┌──────────────┐ docker compose up ┌────────────────┐ │
│ │ Source Code │ ─────────────────────▶│ App Container │ │
│ │ (bind mount)│ │ :3000 │ │
│ └──────────────┘ └────────┬───────┘ │
│ │ │
│ ┌──────────────┐ hot reload / watch ┌────────▼───────┐ │
│ │ Dockerfile │◀── volume change ──────│ DB Container │ │
│ │ .env │ │ postgres:16 │ │
│ │ compose.yml │ └────────────────┘ │
│ └──────────────┘ │
│ │
│ Local: identical env to production, zero "works on my │
│ machine" surprises │
└─────────────────────────────────────────────────────────────┘Use the 'target' field in compose.yml to select the development stage. In CI, build the production stage explicitly with: docker build --target production -t myapp:latest . This avoids building dev dependencies into your production image.
My standard compose.yml includes a healthcheck on the database service and uses 'depends_on' with 'condition: service_healthy'. This prevents the app container from starting before Postgres is actually ready to accept connections—a race condition that causes cryptic startup failures. I also use an anonymous volume for node_modules inside the container, which prevents the host's node_modules (potentially built for a different OS) from overwriting the container's modules via the bind mount.
For local development, I keep a .env.example file in the repository with placeholder values and a .env file in .gitignore. Docker Compose reads .env automatically. For production, I use Cloud Run's secret manager integration or a secrets manager depending on the platform—never environment variables baked into the image. This practice means the Dockerfile contains zero secrets, which is safe to commit and share.
# compose.yml (root of every project)
services:
app:
build:
context: .
dockerfile: Dockerfile
target: development
volumes:
- .:/app
- /app/node_modules # anonymous volume keeps container modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://dev:dev@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
---
# Multi-stage Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS development
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS builder
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/main.js"]A naive Node.js Dockerfile installs all dependencies (including devDependencies) and copies all source files. The resulting image can be 800 MB or more. With multi-stage builds, the production stage copies only the compiled dist/ directory and installs only production dependencies. The result is typically 150-250 MB—a 60-70% reduction. Smaller images mean faster pulls, faster deploys, and a smaller attack surface. For Cloud Run, smaller images also reduce cold-start latency because there's less to decompress.
Never use 'latest' as your image tag in production. Always tag images with the git commit SHA or a semantic version. 'latest' makes rollbacks ambiguous—you lose the ability to quickly redeploy the previous known-good image when something goes wrong in production.
In GitHub Actions, I build and test inside Docker to guarantee the CI environment matches production exactly. The workflow runs 'docker compose up -d' to start the full stack, executes 'docker compose exec app npm run test' to run the test suite, and then builds and pushes the production image. Because the test runs inside the same app container that production will use, there are no surprises from different Node versions or missing native libraries. After tests pass, 'docker build --target production' produces the deployment artifact.
The biggest mistake was not using health checks, which caused intermittent startup failures in CI. The second was bind-mounting the entire project without an anonymous volume for node_modules, which caused native modules built for macOS to break inside the Linux container. The third was using root inside the container in production—most cloud platforms now warn about this, and it's a real security risk. Adding 'USER node' to the production stage fixed all three categories of problems without adding meaningful complexity.