deployment

SkillCloud & infra

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`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the deployment skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/deployment/SKILL.md and read by ahel’s review.

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

StackBase imageNotes
FastAPI / Pythongcr.io/distroless/python3-debian12:nonroot (or python:3.13-slim)UID 65532, no shell
Gogcr.io/distroless/static-debian12:nonrootCGO_ENABLED=0 static, ~10 MB
Next.jsnode:24-bookworm-slimActive LTS; output: "standalone"
Flutter webnginxinc/nginx-unprivileged:1.27-alpinestatic SPA + try_files fallback
Postgrespostgres:18-alpinemanaged/official — do NOT build a custom image

Table B — Coolify build pack

SituationPick
Repo has a DockerfileDockerfile pack (always — CI/prod parity)
No Dockerfile, standard stackNixpacks / Railpack
Static SPA, no serverStatic
Multi-service local parityDocker Compose
CI already builds & pushesDocker Image (deploy prebuilt ghcr image)

If it has a Dockerfile, use the Dockerfile pack.

Table C — Deploy strategy

Change typeStrategy
Backward-compatibleRolling (Coolify default, healthcheck-gated)
Breaking / instant cutover / risky migrationBlue-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 kindMechanism
Build-time non-secretARG
Build-time secret (private dep token)BuildKit --mount=type=secret (NEVER ARG)
Runtime secretCoolify env (Is Secret) / GHA secrets
Cloud authOIDC — 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:

  1. Hetzner VPS + Coolify — cheapest control, EU residency, sustained/always-on/stateful; you own ops. (The combo references/coolify.md runs on; see below.)
  2. Vercel — zero-ops serverless/edge, ideal Next.js, scales to zero for spiky traffic; metered cost climbs at sustained scale, US-default region.
  3. 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.ParseAs[Config]()) 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

RationalizationSTOP — do this instead
:latest is fine for nowPin tag+digest (FROM img@sha256:…); :latest breaks reproducibility and rollback
I'll pass the token as ARGBuildKit --mount=type=secret; ARG persists in docker history
permissions: write-all is simplerDefault-deny; grant per job (packages: write, id-token: write)
Store a registry password in GHA secretsUse OIDC / GITHUB_TOKEN; no long-lived key
Run as root, it's just a containerNon-root UID + read-only rootfs + cap_drop: ALL (add back only NET_BIND_SERVICE to bind <1024)
Skip the healthcheck, the app boots fastNo healthcheck = no rolling gate = downtime / bad version live
Copy the whole repo then RUN installCopy the lockfile first; cache the deps layer
Nixpacks is easier than my DockerfileIf a Dockerfile exists, use it — CI/prod parity
Secrets in compose.yaml env.env (gitignored) / Coolify secret env
Migrate the DB destructively in deployBackward-compatible migrations, or rolling breaks
echo $SECRET to debug CINever; masked vars still leak via set -x and logs
Build once per env with different secretsBuild one image; inject config at runtime (12-factor)

Quick reference

TaskCommand / file
Build with secretDOCKER_BUILDKIT=1 docker build --secret id=npm_token,env=NPM_TOKEN -t app:dev .
Scan imagetrivy image --severity HIGH,CRITICAL --exit-code 1 IMG
Lint Dockerfilehadolint Dockerfile
Lint workflowsactionlint
Run verify gatebash scripts/verify.sh (hadolint+actionlint+trivy+build smoke, local and CI)
Local updocker compose up --watch
Trigger Coolify deploycurl --fail -X POST …/api/v1/deploy?uuid=…&force=false
Roll backCoolify → 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.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
deployment-ericrisco
Source
github.com/ericrisco/rsc-harness