"It works on my machine" is the failure mode this guide exists to delete. It means local execution and staging no longer share a contract: a base image drifted, a runtime moved a patch version, a seed dataset diverged, or an environment variable lives in one place and not the other. The fix is to make the contract explicit — pinned digests, mirrored devcontainers, health-gated orchestration, deterministic seeds — and to validate it automatically. This guide is part of onboarding architecture and friction mapping; for the end-to-end script, see automating runtime parity checks between local and staging, and for single-symptom triage see debugging "works on my machine" runtime drift.

Prerequisites

  • Docker Engine 24+ with the Compose v2 plugin and Buildx.
  • SSH access to a staging host (or a representative reference image) for comparison.
  • jq on the host and a committed lockfile for the primary runtime.

Baseline Environment Definition & Containerization

Parity begins at the image layer. Floating tags drift silently, so pin exact digests, strip build toolchains with multi-stage builds, and run as a non-root user that mirrors staging.

  1. Build a pinned, multi-stage, non-root image:
    # Stage 1: build
    FROM node:20.11.1-alpine3.19@sha256:0000000000000000000000000000000000000000000000000000000000000000 AS build
    WORKDIR /build
    COPY package*.json ./
    RUN npm ci
    COPY . .
    RUN npm run build
    
    # Stage 2: runtime
    FROM node:20.11.1-alpine3.19@sha256:0000000000000000000000000000000000000000000000000000000000000000
    RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup
    WORKDIR /usr/src/app
    COPY --from=build /build/dist ./dist
    COPY --from=build /build/node_modules ./node_modules
    ENV NODE_ENV=production
    USER appuser
    EXPOSE 3000
    CMD ["node", "dist/server.js"]
  2. Diff the local image digest against the registry manifest:
    #!/usr/bin/env bash
    set -euo pipefail
    LOCAL_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' app:local)
    REMOTE_DIGEST=$(docker buildx imagetools inspect "$REGISTRY/app:staging" --format '{{.Manifest.Digest}}')
    if [ "${LOCAL_DIGEST##*@}" != "$REMOTE_DIGEST" ]; then
      echo "DRIFT: local=$LOCAL_DIGEST remote=$REMOTE_DIGEST" >&2
      exit 1
    fi
    echo "Image digest parity confirmed."

macOS (Docker Desktop): match --platform to the host to avoid silent QEMU emulation that hides architecture bugs. WSL2: keep the build context on the Linux filesystem; the 9p layer over /mnt/c cripples COPY performance. Apple Silicon (ARM64): verify native addons compile on linux/arm64, or pin platform: linux/amd64 and accept the emulation cost knowingly.

Devcontainer Specification & IDE Integration

A devcontainer makes the contract reproducible inside the editor: same image, same forwarded ports, same post-create steps.

  1. Reference the orchestration file and pin behavior:
    // .devcontainer/devcontainer.json
    {
      "name": "App Runtime",
      "dockerComposeFile": "../docker-compose.yml",
      "service": "app",
      "workspaceFolder": "/workspace",
      "forwardPorts": [3000, 5432],
      "postCreateCommand": "npm ci && npm run build",
      "customizations": {
        "vscode": {
          "settings": {
            "editor.formatOnSave": true,
            "typescript.tsdk": "node_modules/typescript/lib"
          },
          "extensions": ["dbaeumer.vscode-eslint"]
        }
      }
    }
  2. Validate the resolved configuration:
    #!/usr/bin/env bash
    set -euo pipefail
    devcontainer config --workspace-folder . > resolved.json
    jq -e '.dockerComposeFile and .service' resolved.json >/dev/null && echo "devcontainer resolves"

Sharing these settings across a team is covered in best practices for devcontainer.json in monorepos.

macOS (Docker Desktop): raise VM memory to ≥8GB so postCreateCommand does not OOM on large dependency trees. WSL2: set "workspaceMount" explicitly for non-default distributions.

Service Orchestration & Network Isolation

Mirror staging's startup ordering and resource limits so throttling surfaces locally, not in production.

  1. Gate startup on health and cap resources:
    # docker-compose.yml
    services:
      app:
        build: .
        ports:
          - "3000:3000"
        depends_on:
          db:
            condition: service_healthy
        networks: [app-net]
        deploy:
          resources:
            limits:
              cpus: "1.0"
              memory: 512M
      db:
        image: postgres:16-alpine
        environment:
          POSTGRES_DB: app_db
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: local_dev_pass
        healthcheck:
          test: ["CMD", "pg_isready", "-U", "postgres"]
          interval: 5s
          retries: 5
          start_period: 10s
        volumes:
          - ./seed-data:/docker-entrypoint-initdb.d:ro
        networks: [app-net]
        restart: unless-stopped
    networks:
      app-net:
        driver: bridge
  2. Compare local container state against staging pods:
    #!/usr/bin/env bash
    set -euo pipefail
    docker compose ps --format json | jq -S '[.[] | {name, state: .State}]' > local_state.json
    kubectl get pods -o json \
      | jq -S '[.items[] | {name: .metadata.name, state: .status.phase}]' > staging_state.json
    diff local_state.json staging_state.json || echo "DRIFT: service state mismatch" >&2

When this is where drift first appears, follow mapping microservice dependencies for local dev.

macOS (Docker Desktop): host.docker.internal is injected automatically; native Linux needs explicit extra_hosts. Apple Silicon (ARM64): verify docker inspect <container> | jq '.[0].Platform' so you do not unknowingly run an emulated AMD64 image.

Database Seeding & State Synchronization

State drift is the most common parity gap. Track schema as code and seed with a fixed random seed.

  1. Extract schema and seed deterministically:
    #!/usr/bin/env bash
    set -euo pipefail
    pg_dump -U postgres -d app_db --no-owner --no-privileges --schema-only > schema.sql
    export SEED_RANDOM=42
    npx prisma db seed
  2. Validate the seed payload against staging by checksum:
    #!/usr/bin/env bash
    set -euo pipefail
    LOCAL=$(sha256sum local_seed_dump.sql | awk '{print $1}')
    STAGING=$(sha256sum staging_seed_dump.sql | awk '{print $1}')
    if [ "$LOCAL" != "$STAGING" ]; then
      echo "DRIFT: seed payload divergence detected." >&2
      exit 1
    fi

WSL2: keep PostgreSQL data on the Linux filesystem; 9p mounts degrade I/O severely. Apple Silicon (ARM64): ARM Postgres images can default to a different locale; set POSTGRES_INITDB_ARGS="--locale=C.UTF-8" to guarantee collation parity.

Automated Parity Validation Pipeline

Gate merges on parity so drift never reaches shared branches. Faster, drift-free environments directly improve time-to-first-PR metrics.

  1. Run the parity assertion on every pull request:
    # .github/workflows/parity-check.yml
    name: Runtime Parity Validation
    on: [pull_request]
    jobs:
      parity:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Run parity assertion
            run: |
              ./scripts/validate-parity.sh \
                --local http://localhost:3000 \
                --staging "${{ secrets.STAGING_URL }}" \
                --threshold 0.98 \
                --report ./drift.json
          - uses: actions/upload-artifact@v4
            if: failure()
            with:
              name: parity-drift-report
              path: ./drift.json
  2. Compare health payloads with strict key ordering:
    #!/usr/bin/env bash
    set -euo pipefail
    curl -s http://localhost:3000/health | jq -S . > local_health.json
    curl -s "$STAGING_URL/health" | jq -S . > staging_health.json
    diff local_health.json staging_health.json || { echo "DRIFT: payload structure mismatch" >&2; exit 1; }

This dovetails with the consolidated CI parity validation reference when drift spans containers, secrets, and runners at once.

CI runners: GitHub Actions defaults to AMD64; use docker/setup-qemu-action to also exercise ARM64 paths. WSL2: convert paths with wslpath -u in local hooks before committing.

Rollback / Recovery

If a pinned digest or seed change breaks the local stack, revert config and rebuild from the last known-good baseline:

#!/usr/bin/env bash
set -euo pipefail
git revert --no-edit HEAD
docker compose down -v --remove-orphans
docker compose build --no-cache
docker compose up -d --wait