Ship to vps
Skill teckedd-code2save/ai-build-tools/cli/skills/ship-to-vps
Ships any web app (Next.js / SvelteKit / Remix / FastAPI / etc.) to a Hetzner-class VPS via GitHub Container Registry, GitHub Actions, Caddy, and Cloudflare DNS. Auto-build → migrate → swap → smoke-test, with rollback and observability helpers. Trigger when the user says "ship it", "deploy this", "set up CI/CD", "wire this to my VPS", or "auto-build on push". Designed to take over right where `forge` leaves off — both skills share the `shippability-contract.md` handshake.From its SKILL.md
npx -y skills add teckedd-code2save/ai-build-tools --skill ship-to-vpsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
4 things to look at
- reads credentialsReads from 4 credential sources: `~/.infisical/projects/<slug>.env` and 3 more.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 8 commands, including `forge configure ship-to-vps` and 7 more.
- fetches URLsInstructs the agent to fetch 2 URLs, including https://api.cloudflare.com/client/v4/zones/<zone_id>/dns_records?type=A and 1 more.
SKILL.md
15.7 KB, ~4.1k tokens by cl100k_base, as published. Nobody here has run it
Ship-to-VPS Skill
Take a repo that satisfies references/shippability-contract.md and put it on the user's VPS, behind Caddy, with auto-deploy on push to main. Every step is idempotent — re-running the skill on an already-shipped repo verifies and reports drift, doesn't destroy.
When to use
- After
forgehas scaffolded an app - When the user says: "ship this", "deploy to my VPS", "wire up CI/CD", "set up auto-deploy"
- When an existing app needs to be migrated from manual
docker compose updeploys to a CI flow
Hard rules
- Never break the live container. Every change is staged; the live container is only swapped after migrations succeed AND a new image is pulled. The previous image stays available as
:bootstrap(or as the previous SHA tag) for one-command rollback. - Never write a secret to a file outside
/opt/<slug>/.envon the VPS. Not to GH Secrets (except SSH/Infisical bootstrap), not to the repo, not to shell history. - Never push to a GHCR package without the OCI source label. Manual unlinked pushes break the deploy job's
GITHUB_TOKENauth path. - Never assume the user's VPS has port X free. Probe before binding.
- Never co-tenant another app's Docker network or volume. Each app gets
/opt/<slug>/+ its own Docker network + its own postgres volume (if needed). - Never commit
.env*. Already gitignored across user repos — verify, don't re-add. - If the repo fails
references/shippability-contract.md, do NOT proceed. Either fix the violations (with user approval) or hand back toforgeto re-scaffold.
Inputs the skill needs
Defaults come from
~/.forge/ship-to-vps-config.jsonif it exists. Runforge configure ship-to-vpsto set persistent defaults (VPS host, SSH key, GHCR namespace, Infisical domain). If the config file is absent or a value is unset, ask the user once and remember in project memory.
If unset, ask the user once and remember in a project memory:
| Input | Example | Where it lands |
|---|---|---|
<slug> | perfume-emporio | repo name, Infisical project slug, /opt/<slug>/, container name, Caddy site filename |
<slug_underscored> | derived: perfume_emporio | Postgres user/db name (Postgres rejects hyphens in identifiers) |
<domain> | example.com | Cloudflare A-record, Caddy site :443 host, NEXT_PUBLIC_SITE_URL |
<vps_host> | <your.vps.ip> | GH Secret VPS_HOST, A-record value |
<vps_user> | root | GH Secret VPS_USER, SSH user |
<ssh_key_path> | ~/.ssh/<your-deploy-key> | source for GH Secret VPS_SSH_KEY |
<ssh_key_relpath> | .ssh/<your-deploy-key> | embedded in bin/{logs,rollback} as $HOME/<relpath> |
<ghcr_namespace> | teckedd-code2save | image path ghcr.io/<namespace>/<slug> |
<framework> | nextjs | Dockerfile template selection (Dockerfile.nextjs-prisma7) |
<framework_display> | Next.js 14 | AGENTS.md stack line |
<language> | TypeScript | AGENTS.md stack line |
<orm> | Prisma 7 | AGENTS.md stack line |
<has_db> | true | docker-compose includes postgres + migrations step |
<host_port> | auto-picked from 13000–13999 | web container port binding |
<db_host_port> | auto-picked from 15000–15999 | postgres port binding (127.0.0.1 only) |
<caddy_priority> | 40-60 per existing sites | Caddy site filename ordering |
<infisical_project_id> | derived from .infisical.json | AGENTS.md "Secrets UI" link |
<cloudflare_zone_id> | derived from <domain> | Cloudflare API target |
<public_build_args> | rendered from Infisical NEXT_PUBLIC_* keys | Dockerfile ARG/ENV block |
<public_build_args_yaml> | same data as above, YAML form | deploy.yml build-args: lines |
<public_build_placeholders_yaml> | placeholders for CI build | ci.yml env: block |
Derivation rules
slug_underscored:slugwith-→_ssh_key_relpath:ssh_key_pathwith leading~/strippedhost_port/db_host_port: Step 1 probesssh ... 'ss -tlnp'and picks the lowest free port in rangecaddy_priority: list existing/etc/caddy/sites/*.caddyfiles, pick a 2-digit number not yet used (default 50)infisical_project_id: read from.infisical.jsonworkspaceIdfieldcloudflare_zone_id: extracted from domain viacurl ... /zones?name=<root_domain>public_build_args*: query Infisical prod, filter keys matching^NEXT_PUBLIC_, render three forms:- Dockerfile:
ARG NEXT_PUBLIC_X\nENV NEXT_PUBLIC_X=${NEXT_PUBLIC_X}per key - deploy.yml:
NEXT_PUBLIC_X=${{ vars.NEXT_PUBLIC_X }}per key (indented underbuild-args: |) - ci.yml:
NEXT_PUBLIC_X: "ci-placeholder"per key (or the real value if non-sensitive)
- Dockerfile:
Workflow
Step 0 — Preflight & contract check
- Verify SSH to VPS works:
ssh -i <ssh_key> <user>@<host> 'hostname; docker ps' - Verify
gh auth statusshowswrite:packages+repo+workflowscopes - Verify Infisical project exists for this repo:
cat .infisical.json | jq .workspaceId - Run the shippability contract checker:
~/.claude/skills/ship-to-vps/check-shippability.sh <repo-path>. Exits 0 if shippable, prints PASS/FAIL per item. If any item fails, present the list and offer:- a) Fill the gaps via this skill (Step 3 will render templates for missing infra; app-level gaps like missing
package.jsonscripts go back to forge) - b) Hand back to
forgeto re-scaffold the whole repo properly
- a) Fill the gaps via this skill (Step 3 will render templates for missing infra; app-level gaps like missing
- Verify
<domain>is on a Cloudflare-managed zone the user has API access to (if user opted into Cloudflare integration).
Step 1 — VPS slot provisioning
- Probe a free host port in 13000–13999:
ssh ... 'ss -tlnp | grep -oE ":1[3-4][0-9]{3}"' | sort -u - Create
/opt/<slug>/directory tree - Drop
templates/vps/docker-compose.ymlrendered with this project's variables - Render initial
/opt/<slug>/.envfrom Infisicalprod(Phase 5 ofinfisical-flow.md) - Drop
templates/vps/site.caddyinto/etc/caddy/sites/and reload Caddy - If
<has_db>: bring up only the postgres service first, wait for healthcheck - Do not start the web service yet — there's no image in GHCR to pull yet (handled in Step 4)
Step 2 — Cloudflare DNS
- List existing A-records for the zone:
curl -H "Authorization: Bearer $CF_TOKEN" https://api.cloudflare.com/client/v4/zones/<zone_id>/dns_records?type=A - If
<domain>doesn't exist: create A-record pointing to<vps_host>withproxied=false(Caddy handles TLS termination) - If it exists pointing elsewhere: present diff, ask before overwriting
- Verify:
dig +short <domain>returns<vps_host>(may need ~60s)
Step 3 — Repo: drop shippable artifacts
For each artifact, check if it exists. If yes, diff against template and ask before overwriting. If no, write fresh.
Dockerfile—templates/Dockerfile.<framework>-<orm>(e.g.Dockerfile.nextjs-prisma7).eslintrc.json(if Next.js, and missing).dockerignore(if missing)public/.gitkeep(if Next.js + public/ empty).github/workflows/{ci,deploy,infisical-sync}.yml.github/ISSUE_TEMPLATE/{feature,bug,chore,config}.yml.github/pull_request_template.mdAGENTS.mdCONTRIBUTING.md(3-liner pointing at AGENTS.md)bin/{logs,rollback}(chmod +x)
All artifacts are templated with <slug>, <domain>, <ghcr_namespace>, <vps_host>, <host_port>.
Step 4 — GHCR bootstrap
- Locate an existing live image, in this priority:
ssh ... 'docker ps --format "{{.Image}}" --filter "name=<slug>-web"'→ if it returns a tag, that's the live image. SSH there anddocker tag+docker pushfrom VPS. Most reliable.- Local:
docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^(<slug>-web|<slug>):latest$"→ if matches, push from laptop. - Neither exists: do a one-shot local
docker build .of the repo, tag as:bootstrap+:latest, push.
- Login to GHCR for the push: pipe
gh auth token(must havewrite:packages) throughdocker login ghcr.io -u <gh_user> --password-stdin. Never put the token on a command line. - Tag and push: both
:bootstrap(immutable rollback target) and:latest. Verify withgh api users/<namespace>/packages/container/<slug>/versions --jq '.[0]'. - Verify image works:
docker run --rm <bootstrap-ref> node -e "console.log('ok')"(or framework-equivalent boot check). - Walk user through linking the package to the repo — this is a UI-only step:
- Print:
Open https://github.com/users/<namespace>/packages/container/<slug>/settings → "Manage Actions access" → Add Repository: <slug> with Write role - Wait for user confirmation before proceeding. Test the link worked by checking the package's
repositoryfield viagh api.
- Print:
- Clean up GHCR docker creds from VPS if Step 1's push happened from VPS:
ssh ... 'docker logout ghcr.io'. The CI workflow re-auths fresh each run with ephemeral token.
Step 5 — GitHub Secrets + Variables seeding
- GitHub Secrets (
gh secret setwith stdin to keep secrets out of shell history):VPS_SSH_KEY←cat <ssh_key_path>piped via stdin (NOT--body, which lands on the command line)VPS_HOST←<vps_host>(low-sensitivity,--bodyok)VPS_USER←<vps_user>INFISICAL_CLIENT_ID←INFISICAL_UNIVERSAL_AUTH_CLIENT_IDfrom~/.infisical/projects/<slug>.envINFISICAL_CLIENT_SECRET←INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRETfrom same file
- Discover NEXT_PUBLIC_ keys* by querying Infisical prod env (these are public values safe to log):
sec <slug> prod -- sh -c 'env | grep ^NEXT_PUBLIC_ | sort' - GitHub Variables — for each
NEXT_PUBLIC_*key from step 2:gh variable set "$KEY" --repo <owner>/<repo> --body "$VAL" - Generate
<public_build_args>,<public_build_args_yaml>,<public_build_placeholders_yaml>from the same discovered keys (see "Derivation rules" in Inputs section). These get baked into Dockerfile / deploy.yml / ci.yml when Step 3 re-renders them, OR if Step 3 already ran, edit them now and amend.
If new
NEXT_PUBLIC_*keys are added to Infisical later, theinfisical-sync.ymlworkflow auto-re-mirrors them to GitHub Variables. But the Dockerfile + deploy.yml + ci.yml will still need a manual ARG/build-arg line added — same as~/.claude/skills/ship-to-vps/references/infisical-flow.mdPhase 2 drift note.
Step 6 — First commit + PR
- Branch
chore/ship-to-vps-bootstrap - Stage only the artifacts from Step 3
- Commit with conventional message
- Open PR — body explains everything, links to the shippability contract and Infisical flow
- Wait for CI to pass
Step 7 — Merge + first deploy
- Merge with squash (after user confirmation, or auto if
--yolo) - Wait for
deploy.ymlto fire; monitor job-by-job - If
Build & pushfails: usually GHCR auth — verify Step 4 link step - If
Roll VPS containerfails atmigrate deploy: usually a deps issue — verify Dockerfile satisfies contract item 1 - If smoke test fails: pull container logs via
bin/logsand surface to user
Step 8 — Post-deploy verification
curl -sf -o /dev/null -w "%{http_code}\n" https://<domain>/must return 200ssh ... docker psmust show<slug>-webUp <minutes>with the new image refgh run list --workflow=deploy.yml --limit 1showssuccess- Print the deploy summary: domain, container, image ref, smoke result
Step 9 — Enable drift sync
- Confirm
infisical-sync.ymlis set to run hourly - Trigger once via
workflow_dispatchto validate - Verify it's a no-op when nothing has changed (correct behavior)
Step 10 — Wrap up
- Optionally suggest follow-up issues: observability, smoke test expansion, custom domain TLS validation
- Print runbook quick-reference:
- Deploy: push to
main - Watch:
gh run watch - Logs:
bin/logs - Rollback:
bin/rollback(defaults to:bootstrap) - Manual sync:
gh workflow run infisical-sync.yml
- Deploy: push to
Failure handling
If any step fails:
- Do not proceed. Surface the failure to the user.
- Do not roll back what's already provisioned — VPS slot, DNS record, GH secrets are all idempotent and safe to leave in place.
- Diagnose with
bin/logsif it's a runtime failure, withgh run view --log-failedif it's a CI failure. - Loop back to Step 0 contract check if the failure is shape-related (missing eslintrc, untracked dir, etc.). Often the right fix is to update the scaffold in forge, not patch around it here.
Files this skill ships with
ship-to-vps/
├── SKILL.md (this file)
├── references/
│ ├── shippability-contract.md handshake spec
│ └── infisical-flow.md secrets lifecycle
└── templates/
├── Dockerfile.nextjs-prisma7
├── eslintrc.json
├── dockerignore
├── github/
│ ├── workflows/{ci,deploy,infisical-sync}.yml
│ ├── ISSUE_TEMPLATE/{feature,bug,chore,config}.yml
│ └── pull_request_template.md
├── vps/
│ ├── docker-compose.yml
│ └── site.caddy
├── docs/AGENTS.md
└── bin/{logs,rollback}
All template files use {{name}} markers. The skill's render step must handle two substitution patterns:
- Simple inline:
{{slug}}→perfume-emporio. Same-line replacement, no indentation handling needed. - Multi-line block with indentation preservation:
{{public_build_args}},{{public_build_args_yaml}},{{public_build_placeholders_yaml}}. These expand to multiple lines that must inherit the indentation of the line containing the marker. A naivesed s/X/Y/gwill collapse newlines into literal\nstrings and break YAML/Dockerfile syntax.
A correct render function:
def render(template_text, params):
# Pattern 1: simple inline (no newlines in value)
for k, v in params.items():
if "\n" not in str(v):
template_text = template_text.replace("{{" + k + "}}", str(v))
# Pattern 2: multi-line block (preserve indentation of marker line)
for k, v in params.items():
if "\n" in str(v):
marker = "{{" + k + "}}"
for line in template_text.splitlines():
if marker in line:
indent = line[: len(line) - len(line.lstrip())]
indented = ("\n" + indent).join(v.splitlines())
template_text = template_text.replace(line, line.replace(marker, indented))
return template_text
The dry-run in /tmp/ship-to-vps-dryrun/ uses a simple sed-based renderer that does NOT handle pattern 2 correctly — it's a validation harness only. The real skill must implement the proper renderer (or call out to one).
What ships with it: 19 files
34.8 KB alongside SKILL.md, 3 of them executable
references/
- infisical-flow.md4.3 KB
- shippability-contract.md4.5 KB
templates/
- bin/logsruns930 B
- bin/rollbackruns1.5 KB
- Dockerfile.nextjs-prisma73.1 KB
- dockerignore122 B
- docs/AGENTS.md3.5 KB
- eslintrc.json40 B
- github/ISSUE_TEMPLATE/bug.yml954 B
- github/ISSUE_TEMPLATE/chore.yml525 B
- github/ISSUE_TEMPLATE/config.yml221 B
- github/ISSUE_TEMPLATE/feature.yml1.7 KB
- github/pull_request_template.md1.1 KB
- github/workflows/ci.yml1.0 KB
- github/workflows/deploy.yml3.9 KB
- github/workflows/infisical-sync.yml3.6 KB
- vps/docker-compose.yml1.2 KB
- vps/site.caddy511 B
- check-shippability.shruns2.2 KB