A deployment playbook you can reuse across projects

Every team I work with ships software a little differently. That's fine for the app code, but deployment is where small differences turn into outages. This is the playbook I use to keep it the same across projects, whether it's a single web app, an API, or a stack of containers. It isn't tied to any framework: swap in your own service names, paths, and environment variables.

Contents

  1. What a good deployment looks like
  2. Where to build
  3. The release steps
  4. Config and secrets
  5. Common build and runtime mismatches
  6. The database
  7. Security basics
  8. When something looks wrong
  9. Docker and Compose templates
  10. Handy commands

1. What a good deployment looks like

I aim for five things:

  • Repeatable builds. The same code and dependencies always produce the same image.
  • Prod matches what you tested. No surprises from a different OS, CPU, or library.
  • You know what's live. Every release has a version or commit you can point to.
  • Rolling back is easy. You can go back to the last good version without guessing.
  • Security from the start. Dependencies and secrets are handled up front, not after an incident.

The flow:

Standard deployment flow

  1. Lock the source code and dependencies.
  2. Build in a clean environment.
  3. Package the result as an image.
  4. Copy only what the server needs.
  5. Deploy and run database migrations.
  6. Check health and run a few smoke tests.
  7. Keep the release, or roll back.

2. Where to build

2.1 On your own machine

Fast and easy to debug. The catch is that your laptop's OS, CPU, and libraries often differ from the server's, which is where "works on my machine" comes from. Good for development, risky for production.

2.2 Inside Docker

This is what I recommend. Build in a Linux image that matches production as closely as possible: same distro, same C library (glibc or musl), same CPU architecture. If your laptop is ARM and the server is x86, pass --platform linux/amd64 to docker build.

2.3 On CI, then promote the image

CI builds the image, tags it with a version or git commit, and pushes it to a registry such as GitHub Container Registry, Amazon ECR, or Harbor. The server only pulls and runs it, so it never needs compilers or build tools.

2.4 Without a registry

Use docker save to export the image as a .tar file, copy it to the server with scp or rsync, then run docker load there. This works well for offline networks or very small setups.

2.5 Ship as little as possible

Copy only what the server needs: Compose files, scripts, an env template, and maybe an image file. Don't copy the whole repo. Less on the server means less to attack and faster transfers.

3. The release steps

Lock dependencies. Commit your lockfiles (package-lock.json, pnpm-lock.yaml, go.sum, and so on). Tag the release, or at least write down the exact commit you're deploying.

Build. Use a multi-stage Dockerfile and a .dockerignore so node_modules, .git, and cache files don't end up in the image. If you build on the host, keep build-time secrets out of the production config.

Package. Give every image a clear tag, like app:1.2.3. Avoid relying on latest, because it doesn't tell you what's actually running.

Transfer. Push to a registry, or copy the image file, the Compose file, and a sample env file.

Release. Start the database first and wait for its health check to pass before starting anything that depends on it. Then run migrations. If you need zero downtime, your migrations have to work with both the old and new versions of the app.

Verify. Health checks pass, a few key user flows work, and the logs show no restart loops or repeating errors.

Roll back if needed. Keep the previous image tag ready. Before a risky migration, take a database backup, and make sure you've actually tested restoring it.

4. Config and secrets

4.1 Three kinds of config

  • Infrastructure: database host, port, and TLS settings. Usually set on the server.
  • App: API URLs and feature flags.
  • Runtime: things like NODE_ENV and worker counts.

4.2 Where config lives

  • .env.example lists every required key, with no real values.
  • Real secrets live in a secrets manager (AWS Secrets Manager, Google Secret Manager, Vault) or in a file on the server that's never in git.
  • Never commit a production .env file.

4.3 If a secret might have leaked

Rotate the database password, session or JWT secrets, and API keys. Start with whatever is exposed to the internet.

4.4 I changed .env. Do I need to rebuild?

It depends on when the value is read.

  • Read when the container starts: no rebuild needed, but you do have to recreate the container. Docker doesn't reload env values into a running container, so a plain restart isn't enough.
  • Baked in at build time (for example NEXT_PUBLIC_* values in Next.js): you have to rebuild the app and the image.

To recreate one service:

docker compose -f deploy/docker-compose.yaml up -d --force-recreate web

To check which values the container actually sees (careful, this prints secrets to your terminal):

docker compose -f deploy/docker-compose.yaml exec web sh -c 'printenv | grep -E "DATABASE|API|NODE" | sort'

5. Common build and runtime mismatches

Wrong CPU architecture. An image built for ARM running on x86 (or the other way around) either crashes or runs slowly under emulation. Build with the right --platform, or build on CI that matches the server.

glibc vs musl. Alpine images use musl instead of glibc, and native modules (database drivers, ORMs, crypto libraries) can break or behave differently. Build and test on the same base image you run in production.

Native binaries from your Mac. A binary built on macOS won't run on Linux. Rebuild it inside the Linux image instead of copying it in.

node_modules copied from your machine. Exclude it in .dockerignore and install dependencies inside the Dockerfile with npm ci.

6. The database

  • Store data in a named volume or a bind mount so it survives container restarts.
  • Wait for the database to be ready before running imports or migrations.
  • Schedule regular backups, and take one right before any large migration.

7. Security basics

Containers

  • Run as a non-root user.
  • Use a read-only root filesystem where the app allows it.
  • Set CPU and memory limits so one container can't starve the whole server.

Reverse proxy

  • Handle TLS there, and set sensible timeouts and request size limits.
  • Rate limit sensitive routes like /auth and /api/login.

App code

  • Never build shell commands from user input.
  • Check redirect URLs so the app can't be used as an open redirect.
  • Use long, random secrets for sessions and JWTs.

Dependencies

  • Commit lockfiles and use npm ci (or the equivalent) in CI. Don't run ad-hoc installs on production servers.
  • Run vulnerability scans such as npm audit or OSV-Scanner on a schedule.

8. When something looks wrong

Warning signs

  • Log lines that look like remote code execution: curl | sh, base64 -d | sh, changes to authorized_keys, or processes you don't recognize.
  • CPU or memory spikes that don't match real traffic.
  • Brute-force login attempts or requests to random paths.

What to do

  1. Limit public access with the firewall or security group if needed.
  2. Collect evidence before you change anything: logs, docker inspect, docker diff, and the process list.
  3. Compare the running image's digest with a clean build from CI.
  4. Rotate any secrets that might be exposed.
  5. Redeploy from a trusted source and watch closely for the next 24 to 48 hours.

9. Docker and Compose templates

9.1 .dockerignore

This assumes the web app is built on CI or the host before the image is built, so apps/web/dist is allowed through.

.git
.github
*.md
.env
.env.*
!.env.example
node_modules
**/node_modules
**/dist
!apps/web/dist
**/.next/cache
coverage
*.log

9.2 API Dockerfile (multi-stage)

The build step needs dev dependencies (TypeScript, bundlers), so install everything first, build, then remove the dev dependencies before copying to the final image.

FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY services/api/package.json services/api/package-lock.json ./
RUN npm ci
COPY services/api .
RUN npm run build && npm prune --omit=dev

FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=builder --chown=app:app /app/package.json ./
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
USER app
EXPOSE 4000
CMD ["node", "dist/server.js"]

9.3 Static web Dockerfile (Nginx)

FROM nginx:1.27-alpine
COPY apps/web/dist /usr/share/nginx/html
EXPOSE 80

9.4 Docker Compose

name: acme-local

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    build:
      context: ..
      dockerfile: deploy/Dockerfile.api
    image: myorg/api:${APP_VERSION:?set APP_VERSION}
    env_file:
      - ../services/api/.env
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app
      PORT: 4000
    ports:
      - "127.0.0.1:4000:4000"
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  web:
    build:
      context: ..
      dockerfile: deploy/Dockerfile.web
    image: myorg/web:${APP_VERSION:?set APP_VERSION}
    ports:
      - "127.0.0.1:8080:80"
    depends_on:
      - api
    restart: unless-stopped

volumes:
  pgdata:

9.5 Sample env file

DB_PASSWORD=change-me-to-a-long-random-value
APP_VERSION=1.0.0

10. Handy commands

Build and run

export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker compose --env-file deploy/.env -f deploy/docker-compose.yaml build --no-cache
docker compose --env-file deploy/.env -f deploy/docker-compose.yaml up -d
docker compose -f deploy/docker-compose.yaml ps -a

Logs and debugging

docker compose -f deploy/docker-compose.yaml logs -f --tail=200 api web
docker compose -f deploy/docker-compose.yaml config
docker stats --no-stream

Deploy without a registry

On your machine:

docker save -o deploy/images/api.tar myorg/api:1.0.0
docker save -o deploy/images/web.tar myorg/web:1.0.0
scp deploy/images/*.tar user@vps:/opt/acme/images/

On the server:

docker load -i /opt/acme/images/api.tar
docker load -i /opt/acme/images/web.tar
docker compose --env-file deploy/.env -f deploy/docker-compose.yaml up -d

Docker vs virtual machine architecture