Back to Explore
GuideVibeFix 编辑部Updated Oct 17, 2026

It Works on My Machine Is Not a Deployment Strategy: Docker Production for Vibe Projects

AI-generated Dockerfiles ship with five common defects: single-stage builds, root execution, no health checks, swallowed SIGTERM, and secrets baked into layers. This guide gives you multi-stage templates, a base-image decision table, production compose config, and a launch checklist.

Aerial photo of a container terminal with rows of colorful shipping containers neatly stacked, a metaphor for Docker's standardized packaging

Scope first: what this guide does not cover

Three published guides on this site are easy to confuse with this one, so here is the division of labor:

  • "Self-hosting for vibe projects" answers where to put your service — VPS vs Coolify vs Dokku, cost break-even points;
  • "Dev environment standardization" answers how it runs on your laptop — devcontainers, the 30-minute onboarding test;
  • "Minimal CI/CD pipeline" answers how code ships automatically — the 5-job pipeline.

This guide answers exactly one question: how to build the image and run the container without production surprises. Multi-stage builds, base image choice, non-root execution, health checks, graceful shutdown, production compose — the unglamorous work none of the three above go deep on.

The 5 usual defects in AI-generated Dockerfiles

Asking an AI to "write a Dockerfile" is standard vibe-coding practice — 10 seconds, done. Ship it to production as-is and you will likely hit at least one of these:

  1. Single-stage build, image starts at 1.2GB. Source, node_modules, build tools and .git all end up in the final image; pulling it takes longer than deploying the code;
  2. Running as root. Container escape is not FUD — a compromised root process is a compromised host;
  3. No HEALTHCHECK. The process is wedged but the container reports running, and the load balancer keeps routing traffic into it;
  4. SIGTERM never arrives. Shell-form CMD means sh becomes PID 1 and swallows the signal; rolling updates kill in-flight requests;
  5. Secrets baked into image layers. ENV API_KEY=xxx in the Dockerfile means pushing the image publishes the key.

Let's dismantle them one by one.

Decision table: is your project worth containerizing?

Not every vibe project needs Docker. Take 30 seconds with this table:

ScenarioVerdictWhy
Pure Next.js frontend on Vercel / NetlifySkip itThe platform already handles build and runtime isolation
Full-stack on Railway / Render / Fly.ioOptionalPlatforms accept Dockerfiles, but buildpacks are fine; containerize when you want a locked environment
Own VPS / bare metal / Coolify / DokkuRequiredWithout containers, environment drift will find you sooner or later
Backend + database + Redis + cron workersRequireddocker compose is the only thing that lets local and production share one orchestration
Handing someone a "just run it" deliverableRecommendeddocker compose up beats a 3-page setup doc

Multi-stage builds: only the runtime ships

The whole idea in one sentence: building is not running — use COPY --from to move only artifacts into the final image. Here is a production-ready Node.js template (works for Next.js standalone output or plain Node services):

# ---------- Stage 1: dependencies ----------
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ---------- Stage 2: build ----------
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---------- Stage 3: runtime (the only image that gets pushed) ----------
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
USER nonroot
EXPOSE 3000
ENTRYPOINT ["node", "dist/index.js"]

Three details matter: first, npm ci --omit=dev installs production deps while the build stage installs everything — devDependencies (typescript, eslint) never reach the final image; second, the final stage runs as USER nonroot (distroless ships a nonroot user, uid 65532); third, ENTRYPOINT uses JSON-array (exec) form so node becomes PID 1 and receives signals directly — that fixes defect #4.

Flow diagram of a multi-stage Docker build: the deps, build, and runtime stages chained together, with only the final runtime stage pushed as the production image

Same pattern for Python — swap npm for pip and the runtime for python:3.12-slim:

FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=build /install /usr/local
COPY ./src ./src
RUN useradd -m appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
ENTRYPOINT ["python", "-m", "src.main"]

Base image choice: an order of magnitude apart

Bar chart comparing Node 20 base image sizes by order of magnitude: full ~1000MB, slim ~230MB, alpine ~190MB, distroless ~130MB, labeled as an illustration

Base imageUse whenCaveat
node:20 (full debian)Don't~1GB with a full toolchain, largest attack surface
node:20-slimDefaultHas a shell — you can exec in to debug
node:20-alpineWhen size is criticalmusl, not glibc — sharp, prisma and friends may break; test first
distroless/nodejs20Production end stateNo shell, no package manager — debug via logs and metrics; temporarily swap to slim when troubleshooting

The pragmatic solo-dev route: slim for debugging, distroless for production, switched with one ARG BASE_IMAGE line. One warning about Alpine: AI loves suggesting it because it's "small", but musl-vs-glibc differences can break prebuilt npm binaries with extremely cryptic errors — don't gamble 40MB of savings unless you've tested it.

6 hard rules for a production Dockerfile

  1. Write a .dockerignore. Exclude at minimum node_modules, .git, .env, *.log, .next/cache. Otherwise the build context is gigabytes and COPY . . bakes your local .env into the image — that's how secret leaks happen.
  2. Run as non-root. USER nonroot on distroless; useradd on slim/debian. Combine with --read-only for extra credit (mount a volume for anything that must write).
  3. Always HEALTHCHECK. Without it the orchestrator can't tell alive from wedged:
    HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
      CMD node -e "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)})" || exit 1
    Make start-period cover your cold start, or the container gets executed before it ever wakes up.
  4. Exec-form for signals. CMD npm start (shell form) makes sh PID 1 and SIGTERM never reaches node. Always use JSON-array form: ["node","dist/index.js"].
  5. Secrets injected at runtime, never in layers. No ENV SECRET in the Dockerfile; build-time keys go through build secrets: RUN --mount=type=secret,id=npmrc npm ci.
  6. Pin minor versions. FROM node:20 is not the same image three months from now. Use exact tags like node:20.18.1-slim and let renovate / dependabot track updates.

Graceful shutdown: don't let rolling updates cut requests

A container gets SIGTERM and then SIGKILL, 10 seconds apart by default. Those 10 seconds must: stop accepting new requests, finish in-flight ones, close DB connections. Node.js template:

const server = app.listen(3000);

async function shutdown(signal) {
  console.log(`received ${signal}, draining...`);
  server.close(async () => {          // 1. stop accepting new connections
    await db.end();                  // 2. drain the pool
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 9000).unref(); // 3. 9s backstop
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

For Python (uvicorn/fastapi), raise --timeout-graceful-shutdown and close pools in the lifespan handler. Pair with stop_grace_period: 30s in compose and rolling updates become invisible to users.

Production compose: a template that actually works

services:
  app:
    build: .
    image: registry.example.com/myapp:${TAG:-latest}
    restart: unless-stopped
    env_file: .env.production
    ports: ["127.0.0.1:3000:3000"]   # localhost only; Caddy/Nginx in front
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_started }
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)})"]
      interval: 30s
      start_period: 20s
    deploy:
      resources:
        limits: { cpus: "1.0", memory: 512M }  # a leak restarts the container, not the box
    stop_grace_period: 30s
    logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb",
              "--maxmemory-policy", "allkeys-lru"]

volumes: { pgdata: {} }
secrets: { db_password: { file: ./secrets/db_password.txt } }

Why each choice: ports binds 127.0.0.1 only — Postgres and Redis expose no ports at all; containers talk over the compose network, which is network isolation at zero cost. depends_on with service_healthy (not a bare dependency) stops the app from crash-looping while the DB is still waking up. resources.limits caps memory so a Node leak restarts one container instead of taking down the whole VPS. Log rotation with max-size matters because the default json-file driver grows forever — the classic "why did my VPS die at 3am" story.

Image security minimum: three things one person can finish

  1. Scan after building. docker scout cves myapp:latest or the open-source trivy: trivy image myapp:latest. Care about CRITICALs, run weekly; the fix is usually bumping the base image tag.
  2. Audit for secrets. Run gitleaks across the repo — one command, catches keys before they ship.
  3. Least privilege at runtime. Non-root plus a read-only filesystem (read_only: true, tmpfs where writes are needed) breaks most container-escape chains.

Faster builds: stop waiting 10 minutes per deploy

Multi-stage builds come with a hidden bonus — layer caching: the dependency layers barely change, only the last layers rebuild. In CI, use buildx registry caching:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/myapp:cache \
  --cache-to type=registry,ref=registry.example.com/myapp:cache,mode=max \
  --push -t registry.example.com/myapp:${GITHUB_SHA} .

Typical effect: 6 minutes down to 40 seconds when dependencies don't change. And the iron law of layer caching: COPY package.json always goes before COPY . . — reverse the order and every one-line change reinstalls all dependencies.

Pre-launch checklist (print it, tick it off)

  • ☐ docker build passes in a clean environment (CI); local cache doesn't count
  • ☐ whoami inside the image is not root
  • ☐ docker scout / trivy shows no CRITICAL CVEs
  • ☐ /api/health genuinely checks DB connectivity (not a bare 200)
  • ☐ docker stop exits gracefully within 10s; logs show draining
  • ☐ Database and Redis expose no public ports
  • ☐ Log rotation configured; disk can't fill up
  • ☐ .env.production is in neither the image nor git
  • ☐ Memory limit set; OOM restarts only the container
  • ☐ Rollback plan: the previous image tag is still in the registry, one command to switch back

5 real production incidents (paid for in blood)

  1. Alpine + Prisma = 500s at 3am. The Prisma engine binary won't load under musl; you can't reproduce it on a Mac. Fix: use slim, or set binaryTargets = ["linux-musl"] and actually test it.
  2. npm start ate the SIGTERM. Rolling update SIGKILLed the old container mid-payment-callback. Fix: exec-form ENTRYPOINT plus graceful shutdown code.
  3. HEALTHCHECK interval shorter than cold start. 25s cold start, 10s interval — the container never reaches healthy and compose restarts it forever. Fix: start_period covering the cold start.
  4. Logs filled the disk. json-file has no default cap; three months later the VPS hit 100% disk and Postgres died first. Fix: logging max-size rotation.
  5. .env baked into an image pushed to a public Docker Hub repo. The API key was abused the same day. Fix: .dockerignore + build secrets + rotate every key after a leak (don't just delete the image).

One line to close: the Dockerfile is the first gate of your production environment, and the AI-generated draft is always just a draft — run the checklist above before it goes live.

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

Photo of tall library bookshelves packed with colorful books, a metaphor for a monorepo keeping many packages organized in one place
Guide
The Frontend Changed a Type and Production Broke: Monorepo for Vibe Projects

Three signals tell you it's time for a monorepo: one feature needs 3 PRs, type mismatches only explode in production, and the AI agent keeps missing spots. This guide gives you a copy-paste layout, Turborepo orchestration, shared-type practice, and deployment gotchas.

Developer WorkflowProject BuildingGitHub
Real photo of an industrial emergency stop button on a control panel
Guide
Don't Ship on Prayers: Feature Flags and Emergency Kill-Switches for Vibe Projects

Feature flags decouple deploy from release: code ships first, features turn on later. Four flag types and a selection matrix, a one-table minimal implementation, the fallback iron rule (new features fail-closed, kill-switches freeze), flag-rot governance, and how flags pair with canary releases.

DeploymentBackend EngineeringProject Building
Official Hugging Face blog figure: Claude Code sends a question to the local 2B ColGrep-Agent, which searches with ColGrep over several rounds and returns code locations
News
Stop Letting Claude Code Grep the Whole Repo Itself: ColGrep-Agent, a 2B Local Model, Takes Over Code Search — 34% Fewer Tokens, 40% Lower Cost

On October 12, LightOn published ColGrep-Agent-2B on the Hugging Face blog: an open 2B-parameter code-search agent that runs locally. Across 624 sessions (Claude Code + Sonnet 5.5, 52 questions × 13 repos created in 2026), delegating search to the local small model cut API tokens 34.3% and cache-aware cost 40%, with no statistically significant accuracy change. The first hard ledger of small-model division-of-labor economics.

Developer WorkflowTool TipsModel Updates