teckedd-code2save/ai-build-tools/ship-to-vps

Trust tier 3
  • community
  • ext:[{"type":"hook","description":"Pre/post lifecycle hooks"}]
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install teckedd-code2save/ai-build-tools/ship-to-vps

Semi-extensibleMentions customization, but needs skill-specific knowledge. Read the extensible skills standard

SKILL.md

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 forge has 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 up deploys to a CI flow

Hard rules

  1. 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.
  2. Never write a secret to a file outside `/opt/<slug>/.env` on the VPS. Not to GH Secrets (except SSH/Infisical bootstrap), not to the repo, not to shell history.
  3. Never push to a GHCR package without the OCI source label. Manual unlinked pushes break the deploy job's GITHUB_TOKEN auth path.
  4. Never assume the user's VPS has port X free. Probe before binding.
  5. 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).
  6. Never commit `.env*`. Already gitignored across user repos — verify, don't re-add.
  7. If the repo fails `references/shippability-contract.md`, do NOT proceed. Either fix the violations (with user approval) or hand back to forge to re-scaffold.

Inputs the skill needs

Defaults come from `~/.forge/ship-to-vps-config.json` if it exists.
Run forge configure ship-to-vps to 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:

InputExampleWhere it lands
<slug>perfume-emporiorepo name, Infisical project slug, /opt/<slug>/, container name, Caddy site filename
<slug_underscored>derived: perfume_emporioPostgres user/db name (Postgres rejects hyphens in identifiers)
<domain>example.comCloudflare A-record, Caddy site :443 host, NEXT_PUBLIC_SITE_URL
<vps_host><your.vps.ip>GH Secret VPS_HOST, A-record value
<vps_user>rootGH 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-code2saveimage path ghcr.io/<namespace>/<slug>
<framework>nextjsDockerfile template selection (Dockerfile.nextjs-prisma7)
<framework_display>Next.js 14AGENTS.md stack line
<language>TypeScriptAGENTS.md stack line
<orm>Prisma 7AGENTS.md stack line
<has_db>truedocker-compose includes postgres + migrations step
<host_port>auto-picked from 13000–13999web container port binding
<db_host_port>auto-picked from 15000–15999postgres port binding (127.0.0.1 only)
<caddy_priority>40-60 per existing sitesCaddy site filename ordering
<infisical_project_id>derived from .infisical.jsonAGENTS.md "Secrets UI" link
<cloudflare_zone_id>derived from <domain>Cloudflare API target
<public_build_args>rendered from Infisical NEXT_PUBLIC_* keysDockerfile ARG/ENV block
<public_build_args_yaml>same data as above, YAML formdeploy.yml build-args: lines
<public_build_placeholders_yaml>placeholders for CI buildci.yml env: block
Derivation rules
  • slug_underscored: slug with - → _
  • ssh_key_relpath: ssh_key_path with leading ~/ stripped
  • host_port / db_host_port: Step 1 probes ssh ... 'ss -tlnp' and picks the lowest free port in range
  • caddy_priority: list existing /etc/caddy/sites/*.caddy files, pick a 2-digit number not yet used (default 50)
  • infisical_project_id: read from .infisical.json workspaceId field
  • cloudflare_zone_id: extracted from domain via curl ... /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 under build-args: |)
  • ci.yml: NEXT_PUBLIC_X: "ci-placeholder" per key (or the real value if non-sensitive)

Workflow

Step 0 — Preflight & contract check
  1. Verify SSH to VPS works: ssh -i <ssh_key> <user>@<host> 'hostname; docker ps'
  2. Verify gh auth status shows write:packages + repo + workflow scopes
  3. Verify Infisical project exists for this repo: cat .infisical.json | jq .workspaceId
  4. 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.json scripts go back to forge)
  • b) Hand back to forge to re-scaffold the whole repo properly
  1. 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
  1. Probe a free host port in 13000–13999: ssh ... 'ss -tlnp | grep -oE ":1[3-4][0-9]{3}"' | sort -u
  2. Create /opt/<slug>/ directory tree
  3. Drop templates/vps/docker-compose.yml rendered with this project's variables
  4. Render initial /opt/<slug>/.env from Infisical prod (Phase 5 of infisical-flow.md)
  5. Drop templates/vps/site.caddy into /etc/caddy/sites/ and reload Caddy
  6. If <has_db>: bring up only the postgres service first, wait for healthcheck
  7. Do not start the web service yet — there's no image in GHCR to pull yet (handled in Step 4)
Step 2 — Cloudflare DNS
  1. 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
  2. If <domain> doesn't exist: create A-record pointing to <vps_host> with proxied=false (Caddy handles TLS termination)
  3. If it exists pointing elsewhere: present diff, ask before overwriting
  4. 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.md
  • AGENTS.md
  • CONTRIBUTING.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
  1. Locate an existing live image, in this priority:
  2. ssh ... 'docker ps --format "{{.Image}}" --filter "name=<slug>-web"' → if it returns a tag, that's the live image. SSH there and docker tag + docker push from VPS. Most reliable.
  3. Local: docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^(<slug>-web|<slug>):latest$" → if matches, push from laptop.
  4. Neither exists: do a one-shot local docker build . of the repo, tag as :bootstrap + :latest, push.
  5. Login to GHCR for the push: pipe gh auth token (must have write:packages) through docker login ghcr.io -u <gh_user> --password-stdin. Never put the token on a command line.
  6. Tag and push: both :bootstrap (immutable rollback target) and :latest. Verify with gh api users/<namespace>/packages/container/<slug>/versions --jq '.[0]'.
  7. Verify image works: docker run --rm <bootstrap-ref> node -e "console.log('ok')" (or framework-equivalent boot check).
  8. 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 repository field via gh api.
  1. 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
  1. GitHub Secrets (gh secret set with 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, --body ok)
  • VPS_USER ← <vps_user>
  • INFISICAL_CLIENT_ID ← INFISICAL_UNIVERSAL_AUTH_CLIENT_ID from ~/.infisical/projects/<slug>.env
  • INFISICAL_CLIENT_SECRET ← INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET from same file
  1. Discover NEXT_PUBLIC_* keys by querying Infisical prod env (these are public values safe to log):

``bash sec <slug> prod -- sh -c 'env | grep ^NEXT_PUBLIC_ | sort' ``

  1. GitHub Variables — for each NEXT_PUBLIC_* key from step 2:

``bash gh variable set "$KEY" --repo <owner>/<repo> --body "$VAL" ``

  1. 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, the infisical-sync.yml workflow 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.md Phase 2 drift note.
Step 6 — First commit + PR
  1. Branch chore/ship-to-vps-bootstrap
  2. Stage only the artifacts from Step 3
  3. Commit with conventional message
  4. Open PR — body explains everything, links to the shippability contract and Infisical flow
  5. Wait for CI to pass
Step 7 — Merge + first deploy
  1. Merge with squash (after user confirmation, or auto if --yolo)
  2. Wait for deploy.yml to fire; monitor job-by-job
  3. If Build & push fails: usually GHCR auth — verify Step 4 link step
  4. If Roll VPS container fails at migrate deploy: usually a deps issue — verify Dockerfile satisfies contract item 1
  5. If smoke test fails: pull container logs via bin/logs and surface to user
Step 8 — Post-deploy verification
  1. curl -sf -o /dev/null -w "%{http_code}\n" https://<domain>/ must return 200
  2. ssh ... docker ps must show <slug>-web Up <minutes> with the new image ref
  3. gh run list --workflow=deploy.yml --limit 1 shows success
  4. Print the deploy summary: domain, container, image ref, smoke result
Step 9 — Enable drift sync
  1. Confirm infisical-sync.yml is set to run hourly
  2. Trigger once via workflow_dispatch to validate
  3. Verify it's a no-op when nothing has changed (correct behavior)

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 95 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
2
7-day trend score
5

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Extension points

  • hookPre/post lifecycle hooks

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/teckedd-code2save/ai-build-tools/ship-to-vps/badge)](https://verified-skill.com/skills/teckedd-code2save/ai-build-tools/ship-to-vps)

Something wrong with this skill or its page? Tell us.