Deployment logo

Deployment

Community
ericrisco
deployment

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

Overview

Publisherericrisco
Repositoryrsc-harness
Skill namedeployment
Stars
239
Forks
24
Bundled files
6
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 6 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by ericrisco on GitHub. Read the source before you install it.

Installation

Install the Deployment AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/ericrisco/rsc-harness/tree/main/skills/deployment
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/ericrisco/rsc-harness.git /tmp/rsc-harness
mkdir -p .claude/skills
cp -r /tmp/rsc-harness/skills/deployment .claude/skills/deployment
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Deployment in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Deployment on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Deployment is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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.

text
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.

dockerfile
# 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"]
dockerfile
# 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`
text
# .dockerignore — write this before your first build
.git
node_modules
.env*
dist
.next
__pycache__
*.log
coverage
Dockerfile*
compose*
README.md
.github
bash
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

yaml
# 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

yaml
# .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.
bash
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)

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

python
# 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.

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Deployment AI skill do?

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

Why use Deployment on TypingMind?

Because you install it once and use it with any model. Deployment is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Deployment in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/ericrisco/rsc-harness/tree/main/skills/deployment. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Deployment?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Deployment?

As many as you like. As long as a model supports skills, you can use Deployment with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Deployment AI skill free?

Yes. It is published on GitHub by ericrisco under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇