Ship to vps
From product spec to live URL — scaffold a backend with Forge, ship to your VPS with ship-to-vps
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.
One thing to look at
- 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.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
15.7 KB, 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).