mcpbeat Sign in

Deployment Skill for Claude

Use when taking an app from source to live: choosing the deploy target from requirements (Hetzner+Coolify vs Vercel vs a third), then wiring container → CI → registry → host with build secrets, healthchecks and rollback. NOT one platform's mechanics (that is `coolify`, `vercel`, `railway`, `render`), NOT the Dockerfile alone (that is `docker`).

21k tokens
context cost
the whole folder, loaded on every use
8
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill deployment

What comes with it

65 482 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/coolify.md
references/dockerfiles-by-stack.md
references/github-actions.md
references/hosting-targets.md
scripts/verify.sh

The instruction itself

13 sections, as written by the author

Ship it — Docker, GitHub Actions, and a deploy target (Coolify · Vercel · Hetzner)

Take any app in this repo from source → hardened container → green CI/CD → live on the right

host, with secrets that never leak into image layers or logs, and a defined rollback path.

source → Dockerfile (multi-stage) → CI (lint·test·build·scan) → registry (ghcr) → target (Coolify·Vercel·Hetzner, rolling) → live + rollback
                                                                                     ▲
                                                              choose via references/hosting-targets.md

Out of scope — say so and stop: Kubernetes / Helm / ECS / Nomad orchestration; cloud IaC

(Terraform, Pulumi, CloudFormation — only the GHA↔cloud OIDC handshake is covered, not

provisioning); application runtime code and DB schema/migration logic (the per-stack skills at the

bottom own what runs *inside* the container).

Decision rules

Consult these first. They settle 90% of choices before you write a line.

Table A — Base image by stack

| Stack | Base image | Notes |

| --- | --- | --- |

| FastAPI / Python | gcr.io/distroless/python3-debian12:nonroot (or python:3.13-slim) | UID 65532, no shell |

| Go | gcr.io/distroless/static-debian12:nonroot | CGO_ENABLED=0 static, ~10 MB |

| Next.js | node:24-bookworm-slim | Active LTS; output: "standalone" |

| Flutter web | nginxinc/nginx-unprivileged:1.27-alpine | static SPA + try_files fallback |

| Postgres | postgres:18-alpine | managed/official — do NOT build a custom image |

Table B — Coolify build pack

| Situation | Pick |

| --- | --- |

| Repo has a Dockerfile | Dockerfile pack (always — CI/prod parity) |

| No Dockerfile, standard stack | Nixpacks / Railpack |

| Static SPA, no server | Static |

| Multi-service local parity | Docker Compose |

| CI already builds & pushes | Docker Image (deploy prebuilt ghcr image) |

If it has a Dockerfile, use the Dockerfile pack.

Table C — Deploy strategy

| Change type | Strategy |

| --- | --- |

| Backward-compatible | Rolling (Coolify default, healthcheck-gated) |

| Breaking / instant cutover / risky migration | Blue-green: two Coolify resources + domain swap |

| Want gradual % traffic (canary) | Canary = release to a small subset, watch metrics, then ramp. Vanilla Coolify has no traffic split — emulate with feature flags (in-app % gating) or a blue-green pair behind a flagged path |

Table D — Secret delivery

| Secret kind | Mechanism |

| --- | --- |

| Build-time non-secret | ARG |

| Build-time secret (private dep token) | BuildKit --mount=type=secret (NEVER ARG) |

| Runtime secret | Coolify env (Is Secret) / GHA secrets |

| Cloud auth | OIDC — never a stored key |

Docker — the canonical multi-stage shape

One process per container: no supervisord-managed bundles, let the orchestrator scale.

# syntax=docker/dockerfile:1
# ---- builder: full toolchain, deps cached before source ----
FROM <builder-base> AS builder
WORKDIR /app
COPY <lockfile> <manifest> ./           # lockfile FIRST → cached dep layer
RUN <install-deps-from-lockfile>        # changes only when the lockfile changes
COPY . .                                # source last
RUN <build>

# ---- runtime: minimal, non-root, no toolchain ----
FROM <runtime-base>                      # distroless / -slim / unprivileged nginx
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/<artifact> ./
USER nonroot:nonroot
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD ["<readiness-probe>"]             # exec-form (distroless has no shell)
CMD ["<entrypoint>", "--host", "0.0.0.0", "--port", "8000"]
# GOOD: secret consumed in-layer, never persisted
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
# BAD: ARG bakes the token into image history forever
ARG NPM_TOKEN
RUN npm ci   # token now visible in `docker history`
# .dockerignore — write this before your first build
.git
node_modules
.env*
dist
.next
__pycache__
*.log
coverage
Dockerfile*
compose*
README.md
.github
DOCKER_BUILDKIT=1 docker build --secret id=npm_token,env=NPM_TOKEN -t app:dev .

→ full per-stack Dockerfiles: references/dockerfiles-by-stack.md · image-authoring depth

(shrinking, base-image choice, cache busting): ../docker/SKILL.md

docker-compose for local dev + Postgres

# compose.yaml — Compose Spec, no `version:` key
services:
  app:
    build:
      context: .
      target: dev                       # dev stage of the multi-stage Dockerfile
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - .:/app                          # bind mount → hot reload
      - /app/.venv                      # anonymous volume guards container deps
    environment:
      DATABASE_URL: postgres://postgres:postgres@db:5432/app_dev
    develop:
      watch:
        - { path: ./pyproject.toml, action: rebuild }
        - { path: ./app, action: sync, target: /app/app }
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:18-alpine
    ports:
      - "127.0.0.1:5432:5432"           # host-only; NEVER 0.0.0.0 in prod
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: app_dev
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app_dev"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:
  • GOOD: bind-mount source for dev hot reload; BAD: bind-mount source over a prod image (it shadows the baked build).
  • GOOD: bind Postgres to 127.0.0.1; BAD: bind it to 0.0.0.0 in prod (publicly reachable DB).

→ prod overlay + mailpit: references/dockerfiles-by-stack.md

GitHub Actions — least-privilege pipeline

# .github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:
permissions:
  contents: read                        # default-deny; escalate per job
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: bash scripts/verify.sh
  build-push:
    needs: verify
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=semver,pattern={{version}}
      - uses: docker/build-push-action@v7
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true
      - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
        with:
          image-ref: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
          exit-code: "1"
          severity: "HIGH,CRITICAL"
          ignore-unfixed: true
  • GOOD: scoped per-job permissions (only build-push gets packages: write / id-token: write).
  • BAD: blanket permissions: write-all — any compromised step can push images or mint tokens.
  • GOOD: third-party actions pinned to a full commit SHA with a version comment (@<sha> # v0.35.0). In the March 2026 trivy-action supply-chain incident (GHSA-69fq-xp46-6x23 / CVE-2026-33634), 76 of 77 tags were force-pushed to credential-stealing malware; the advisory's named known-safe ref is v0.35.0 (commit 57a97c7e7821a5776cebc9bb87c984fa69cba8f1), the one clean tag still pointing at the real master HEAD. A moving tag would have pulled the malware; this SHA pin does not. Let Dependabot bump the SHA once upstream re-tags cleanly.

→ matrix, reusable workflows, OIDC-to-cloud, environments/approvals, releases: references/github-actions.md ·

workflow-syntax depth: ../github-actions/SKILL.md

Choosing a deploy target (3 options)

Never recommend a single host. **Gather requirements → recommend exactly three targets with

trade-offs**, so the choice is made with eyes open. The canonical slate:

  • Hetzner VPS + Coolify — cheapest control, EU residency, sustained/always-on/stateful;

you own ops. (The combo references/coolify.md runs on; see below.)

  • Vercel — zero-ops serverless/edge, ideal Next.js, scales to zero for spiky traffic;

metered cost climbs at sustained scale, US-default region.

  • A third that fits the case's sharpest constraint — Railway (tiny/simple, predictable

bill), Fly.io (true global edge, 30+ regions), or a hyperscaler (enterprise compliance).

Requirements to gather first: expected total/concurrent users · traffic shape (steady vs

spiky) · budget ceiling · data region/residency & compliance · team ops comfort · scaling

needs (scale-to-zero, global latency) · stateful needs (own DB/queue/websockets).

Quick steer: Next.js + spiky traffic + ops-averse → Vercel. Cost-sensitive / EU-resident /

sustained / own stateful services → Hetzner+Coolify. The Dockerfile this skill produces is the

escape hatch — start on Vercel, move to Hetzner+Coolify when the bill grows, same artifact.

→ deep coverage (limits, regions, pricing, decision matrix, worked examples): references/hosting-targets.md

Coolify — wiring the chosen target

Only the parts that touch the pipeline; the platform walkthrough lives elsewhere.

  • Pick the Dockerfile build pack when a Dockerfile exists — same artifact CI builds, full control, prod/CI parity.
  • Set Ports Exposes to the container port your app listens on (e.g. 8000); Traefik routes the domain to it.
  • Set the Health Check path/port → this is what gates the rolling swap to the new container.
  • Mark sensitive env vars Is Secret — encrypted at rest, masked in logs and UI.
  • Enable GitHub App auto-deploy on push, OR call the deploy webhook from CI (one or the other, not both).
  • Rollback = redeploy a previously stored image in one click; pair with backward-compatible migrations.
curl --fail -X POST \
  -H "Authorization: Bearer $COOLIFY_TOKEN" \
  "https://coolify.example.com/api/v1/deploy?uuid=$APP_UUID&force=false"

→ persistent storage, custom domains + Let's Encrypt, per-PR previews, CPU/memory limits, blue-green:

references/coolify.md and ../coolify/SKILL.md

Secrets flow (GitHub → registry → Coolify)

GitHub secrets / OIDC ──mint short-lived creds──▶ build pushes to ghcr.io (no key stored)
     │                                                        │
     └──── nothing long-lived in a workflow file             ▼
                                       Coolify pulls (deploy-scoped registry cred)
                                                     │
                                                     ▼
                                runtime env injected by Coolify (encrypted at rest)
  • A secret crosses at most one trust boundary per hop — never forward a GHA secret into the running container; let Coolify inject runtime env.
  • Nothing long-lived lives in a workflow file: GITHUB_TOKEN and OIDC tokens are minted per run and expire.
  • ${{ }} secrets are masked in logs, but set -x and echo "$SECRET" defeat the mask — forbid both.

12-factor config & observability

Config from env, validated at boot, fail-fast — a bad config crashes on startup, never at request

time. Idiom per stack: pydantic-settings BaseSettings (raises at import), zod envSchema.parse(process.env)

(throws at boot), env.Must(env.ParseAsConfig) for Go (exits at boot).

Log JSON to stdout (slog for Go, structlog/uvicorn JSON for FastAPI, pino for Next.js); never log

secrets; expose /healthz (liveness, no deps) + /readyz (checks deps).

# FastAPI: liveness is dependency-free; readiness probes the DB so a node that
# can't reach Postgres never takes traffic during the rolling swap.
@app.get("/healthz")
async def healthz() -> dict[str, str]:
    return {"status": "ok"}

@app.get("/readyz")
async def readyz() -> dict[str, str]:
    await db.execute("SELECT 1")   # raises 500 if the DB is unreachable
    return {"status": "ready"}

Anti-patterns — rationalizations → STOP

| Rationalization | STOP — do this instead |

| --- | --- |

| :latest is fine for now | Pin tag+digest (FROM img@sha256:…); :latest breaks reproducibility and rollback |

| I'll pass the token as ARG | BuildKit --mount=type=secret; ARG persists in docker history |

| permissions: write-all is simpler | Default-deny; grant per job (packages: write, id-token: write) |

| Store a registry password in GHA secrets | Use OIDC / GITHUB_TOKEN; no long-lived key |

| Run as root, it's just a container | Non-root UID + read-only rootfs + cap_drop: ALL (add back only NET_BIND_SERVICE to bind <1024) |

| Skip the healthcheck, the app boots fast | No healthcheck = no rolling gate = downtime / bad version live |

| Copy the whole repo then RUN install | Copy the lockfile first; cache the deps layer |

| Nixpacks is easier than my Dockerfile | If a Dockerfile exists, use it — CI/prod parity |

| Secrets in compose.yaml env | .env (gitignored) / Coolify secret env |

| Migrate the DB destructively in deploy | Backward-compatible migrations, or rolling breaks |

| echo $SECRET to debug CI | Never; masked vars still leak via set -x and logs |

| Build once per env with different secrets | Build one image; inject config at runtime (12-factor) |

Quick reference

| Task | Command / file |

| --- | --- |

| Build with secret | DOCKER_BUILDKIT=1 docker build --secret id=npm_token,env=NPM_TOKEN -t app:dev . |

| Scan image | trivy image --severity HIGH,CRITICAL --exit-code 1 IMG |

| Lint Dockerfile | hadolint Dockerfile |

| Lint workflows | actionlint |

| Run verify gate | bash scripts/verify.sh (hadolint+actionlint+trivy+build smoke, local and CI) |

| Local up | docker compose up --watch |

| Trigger Coolify deploy | curl --fail -X POST …/api/v1/deploy?uuid=…&force=false |

| Roll back | Coolify → redeploy prior image |

Pre-ship checklist

  • [ ] Runs as non-root
  • [ ] Base image pinned (tag + digest)
  • [ ] .dockerignore present
  • [ ] HEALTHCHECK hits a real readiness path
  • [ ] No secrets in layers or logs
  • [ ] Least-privilege GITHUB_TOKEN
  • [ ] trivy clean (no HIGH/CRITICAL)
  • [ ] Rollback path known

Project grounding (02-DOCS)

In a project with a 02-DOCS/ layer (the harness Karpathy wiki), read

02-DOCS/wiki/stack/deployment.md first and stay consistent with it. Create or update it with this

project's real choices — base-image/container choices, the CI pipeline, the target config, the

secrets flow, the rollback strategy — index it in 02-DOCS/wiki/index.md (the Knowledge map root

CLAUDE.md points to), and bump its Updated date in the same change. No 02-DOCS/ layer? Skip

silently (optionally suggest harness) — technical conventions are *recorded, not gated*; never

block the task on this.

Hand off

  • Platform mechanics once the target is chosen: ../coolify/SKILL.md, ../vercel/SKILL.md, ../railway/SKILL.md, ../render/SKILL.md, ../fly-io/SKILL.md, ../hetzner/SKILL.md.
  • ../secure-coding/SKILL.md — input validation, authn/z, and secret-handling this skill assumes the app already does.
  • ../harness/SKILL.md — 01-TOOLS provider creds (Stripe, Postgres, OAuth…) that become runtime env on the target.
  • ../fastapi/SKILL.md, ../nextjs/SKILL.md, ../go/SKILL.md, ../flutter/SKILL.md, ../postgresdb/SKILL.md — the application code that runs inside the container; this skill stops at that boundary.

How to use it

Copy the folder

Take ericrisco/deployment from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.