deploy-niyoj — one button, one SSH connection, no CI
git push triggers nothing. Push is "save my work"; Deploy is "ship it". Two
deliberate acts, and that separation is the entire point of this variant.
your laptop
┌─────────────────────┐
│ NiYoj [ Deploy ] │
└──────────┬──────────┘
│ ssh -i <key> -o BatchMode=yes (script on stdin)
▼
VPS /opt/<app>/deploy/deploy.sh
│
┌────────────────┴────────────────┐
nginx (front-door) docker compose
├─ static frontend (dist/) ├─ api
├─ /api/ → 127.0.0.1:18000 ├─ workers
├─ maintenance gate (flag) ├─ postgres
└─ TLS via certbot └─ redis
Everything downstream of the trigger is identical to deploy-wizard's CI
playbook. Adopting this one on a project that already follows that pattern means
deleting .github/workflows/deploy.yml and adding the app to NiYoj —
deploy.sh, nginx, the maintenance gate and the backups all stay as they are.
Full file templates (deploy.sh, nginx.conf.template, the SSH invocation, the
app entry) are in references/templates.md. Read it before scaffolding; do not
retype them from memory.
The one constraint everything else follows from
NiYoj connects with BatchMode=yes. A deploy can never answer a prompt.
That single fact rules out: a passphrase on the deploy key, a sudo password,
read anywhere in a script, apt without DEBIAN_FRONTEND=noninteractive,
certbot without --non-interactive, a docker login prompt. Each of those
doesn't error — it hangs, holding the maintenance flag up until the connection
times out. When a NiYoj deploy stalls with no output, this is the first thing to
check.
Phase 0 — which job is this
Ask only if the request is ambiguous; usually it's obvious:
- Scaffold — new project, no deploy artifacts. Phases 1–3.
- Convert — project has
.github/workflows/deploy.yml. Phase 1 (the conversion), then verify. - Diagnose — a deploy failed or hung. Skip to Diagnosing a failed deploy.
Establish the app slug, the subdomain, the server, and the branch that ships. Never scaffold against a guessed subdomain.
Phase 1 — converting off CI
- Delete
.github/workflows/deploy.yml. Not disable, not rename — delete. Two triggers racing on the samegit reset --hardis a corrupted checkout waiting to happen, and "keeping it just in case" is exactly how that races. - Move the DB snapshot from
.githooks/pre-pushintodeploy.sh. With push and deploy separated, a pre-push hook fires at a moment that has nothing to do with production. Drop the hook; the pre-deploy snapshot inside the script is now the guarantee. - Everything else —
deploy.sh, nginx template, maintenance page, backup functions, health endpoint — stays byte-identical. Say so rather than rewriting working files.
What "delete the workflow" does and does not mean
Delete the workflow that deploys. Keep — or add — a workflow that only checks.
CI bundles two things people assume are one: verification (build it clean and run the tests on a machine that isn't yours) and the deployment trigger (and if that passed, ship it). This skill replaces the second. It has no quarrel with the first.
| Workflow | Verdict | Why |
|---|---|---|
Deploys — SSHes to the box, runs deploy.sh, holds DEPLOY_SSH_KEY |
Delete | Two triggers racing one git reset --hard is a corrupted checkout. This is the rule, and it is absolute |
| Only builds, tests, lints, typechecks — never touches the server, holds no deploy secret | Keep | It cannot race: it never reaches the box. It gives you a red X before you press Deploy |
The distinction is server access, not the file's existence. A workflow with no
ssh, no DEPLOY_SSH_KEY, and no appleboy/ssh-action is inert as far as this
playbook is concerned.
Worth saying out loud to a solo developer, who is the usual audience here: the verification half of CI is the half that still pays when you work alone. Nothing about "you forgot to commit a file" or "that dep was only in your global install" depends on team size — and solo is arguably worse, since nobody else ever pulls your branch and hits the missing file. It is the trigger half that stops paying, because push-to-ship is a coordination mechanism and there is nobody to coordinate with. So when converting, offer to keep or add a check-only workflow rather than assuming the user wants no CI at all. If they'd rather have nothing, that is theirs to choose — say what they're giving up in one line and move on.
If a check-only workflow exists, the release ritual gains a step between push and Deploy: wait for the green check. That is the whole benefit; skipping it means paying the CI minutes for nothing.
Phase 2 — the artifacts
Scaffold into the repo, filled in with the real values from Phase 0:
| File | Notes |
|---|---|
deploy/deploy.sh |
Ten phases, in order. references/templates.md |
deploy/nginx.conf.template |
Maintenance-flag rewrite is the part that matters |
deploy/maintenance.html |
Auto-refreshing static page; brand it |
GET /api/health |
Public, no auth, no DB touch, returns {"ok": true} |
useMaintenanceMode + modal |
Poll health, watch content-type, latch sticky |
| localStorage persistence | In-progress UI state, saved on every change |
"Brand it" means the app's brand. maintenance.html and the modal wear what the
app already ships — its logo, palette and type; a maintenance page is not the moment
to invent a look. Nothing to inherit and you must pick → the shared design floor
governs the pick: read references/anti-slop.md beside the installed skills, trying
../references/anti-slop.md first (the install.sh layout), then
../../references/anti-slop.md (git checkout, plugin cache). Neither resolves → say
so in one line and stay on the system font stack and the app's own colours.
Idempotency is the whole design. Every phase is detect → skip-if-done → otherwise-do. A new app and the 200th redeploy take the identical path; the clone is the only conditional and everything after it is a hard reset. Re-running must be safe at any moment — which is exactly what happens when someone presses Deploy twice because they weren't sure the first one took.
Exit non-zero on failure. The exit code is NiYoj's only verdict: it colours
the app card red and stamps the time. set -euo pipefail at the top, die on
every gate, and || true only where failure is genuinely fine (the prune,
the submodule update). A script that swallows errors reports a green deploy of a
broken box.
The maintenance gate, and why the trap is non-negotiable
While a deploy is in flight every request — /api/* included — is rewritten to
/maintenance.html with a 200, never a 503: some browsers and proxies cache
5xx and strand users on the maintenance page after the deploy finishes. The
if (-f ...) runs per request, so flipping the flag needs no nginx reload.
trap 'rm -f "$MAINT_FLAG"' EXIT ERR HUP INT TERM
Manual deploys have a failure mode CI never had: you close the laptop, or the Wi-Fi drops. The SSH session dies, remote bash takes SIGHUP, the trap fires, the flag clears, and the site comes back on the old build. Without it, a dropped connection leaves the site maintenance-stuck until you reconnect. Test it — kill the connection mid-deploy and confirm the flag is gone. That test is part of delivering this skill, not an optional extra.
Gates worth keeping
nginx -tbeforesystemctl reload— never reload a config that doesn't parse.- Health-poll before clearing maintenance, 45 × 2 s, then
die. If the API never came up, the site stays on the maintenance page rather than serving a broken app. docker compose psafterup— a container notrunningis a failed deploy even whenupreturned 0.- Prune before
up --build, never after. Small VPS disks fill fast when a pip install dies mid-stream. Images belonging to running containers survive, so the live site keeps serving. - A closing line you can grep for.
ok "Live"as the last output means "scroll to the bottom" is a complete verification.
Write the script for someone watching — one say per phase, ok per success,
die with the reason per failure. NiYoj streams the log into a popup that can be
closed and reopened while the deploy keeps running.
Phase 3 — the human-only checklist
Print it; never attempt these steps yourself.
- VPS reachable; DNS
Arecord for the subdomain points at it. ssh-keygen -t ed25519 -f ~/.ssh/niyoj -C niyoj— press Enter twice, no passphrase. Public half intoauthorized_keys. (NiYoj's SSH key panel does both halves: generate, install over a one-time password login, verify with the key alone.)- Non-root deploy user?
echo "deploy ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/deploy—deploy.shcallssudoand BatchMode cannot type a password. - Add the server in NiYoj → Test connection → save. It reports the box's kernel and git version, or the actual SSH error.
- Private repo? Deploy key on the box, one key per repo with a per-app host
alias (
git@github-<app>:) pinned in the server's~/.ssh/config;ssh -T git@<alias>must succeed as the deploy user. A single shared key across repos is a lateral-movement gift. - Add the app: server, repo, branch,
/opt/<app>. Scan for apps walks/app,/opt,/srv,/var/www,/root,/homefor git checkouts and imports an existing box in one click — don't hand-write entries for anything already on disk. - Deploy. First run installs everything; expect several minutes.
- Verify:
✓ Livein the log, HTTPS on the subdomain,/api/healthreturns{"ok": true},docker compose psall running. - Deploy a second time immediately. Idempotency is only proven by the boring second run.
The release ritual
Manual deploy means the discipline CI used to enforce is now the human's. These steps, in this order, every time:
- Merge and push to
main— user-confirmed. NiYoj deploysorigin/<branch>, not the working tree. Do NOT merge orgit pushunasked; confirm with the user first, every time. Unpushed commits do not ship — this catches everyone out exactly once, and it is the single most common mistake in this model. - If a check-only workflow exists, wait for its green check. It is the only thing left that reads the code on a machine that isn't yours; deploying past a red X is deploying a build you know is broken.
- Check the app card: right server, right branch, right directory.
- Deploy. Watch to
✓ Live, or to the✗and its reason. - Smoke test the real URL — load the app, hit one authed endpoint, confirm the maintenance page is gone.
Deploy one app at a time when they share a box: two simultaneous
docker system prune -af runs will happily delete each other's build cache.
Rollback
git reset --hard origin/<branch> means the branch is the deployed state, so
rollback is a git operation, not a NiYoj one:
git revert <bad-sha> # then confirm with the user before: git push — then press Deploy
Do NOT git push unasked, even mid-incident. This is invoked while a deploy
is failing, which is exactly when an unconfirmed push does the most damage —
prepare the revert, then get the user's go-ahead before it leaves your machine.
git revert, not reset --hard + force-push: reverting keeps the history other
checkouts already have, and the next deploy is an ordinary fast-forward.
Emergency pin to an exact commit → set the app's custom command temporarily
(see references/templates.md), and clear the field once main is healthy
or the app is silently frozen at that SHA forever.
Migrations are forward-only. Reverting code does not un-apply a migration. If the bad release migrated the schema, restore from the pre-deploy snapshot or ship a compensating migration with the next number up — never edit an applied one.
Diagnosing a failed deploy
| Symptom | Look here first |
|---|---|
| Hangs with no output, no error | Something is prompting — key passphrase, sudo password, apt without DEBIAN_FRONTEND, certbot without --non-interactive |
| Card red, log ends mid-phase | The die that fired names the gate; read the line above it |
| Site stuck on maintenance page | The trap didn't fire or isn't there. rm /opt/<app>/maintenance.flag on the box, then fix the trap |
| Deploy green, change not live | Committed but not pushed. origin/<branch> is what ships |
| "no such file deploy/deploy.sh" | Repo has no deploy script — fine for a static site, the pull is the deploy |
| Build fails on disk space | Prune ran after up --build instead of before, or two apps deployed at once |
| Worked yesterday, broken today, no commits | Someone edited files on the server; the hard reset took them away |
Rules
- Merging and pushing are user-confirmed, always. Push before Deploy is the order of operations, not permission to push. Mid-incident is when this matters most.
- Nothing in the deploy may prompt. BatchMode is the constraint the whole design bends around.
- The repo is the only place changes live. Editing files on the server is erased by the next hard reset, without a word.
- Push before Deploy.
origin/<branch>ships; the working tree does not. - The exit code is the verdict. Never
|| truea real failure. apps.jsonis never committed. It carries hosts, users, and key paths — gitignore it; NiYoj keeps it beside the binary.- Secrets never go in the custom-command field.
apps.jsonis plain local JSON. Environment values live in a.envon the server, referenced by compose. - One deploy key per repo, one host alias per key.
- Delete the CI workflow that deploys; keep one that only checks. The rule is about server access, not about the file existing. A build-and-test workflow never touches the box, so it cannot race — and it is the half of CI that still pays when you work alone.
- Never run the deploy yourself. Scaffold, verify, and hand the human the checklist. Pressing Deploy is theirs; so is anything touching DNS, keys, or the server.
- Sibling skill:
deploy-wizardis this same architecture with GitHub Actions as the trigger. Changes to the shared parts (deploy.shphases, nginx template, maintenance gate, backups) belong in both.