Deploy hooks
A deploy hook is a task that doesn’t run on a schedule. There’s no
cron on it — instead, your CI pokes it through RunWisp’s REST API
whenever it wants a deploy to happen. The payoff: every deploy gets
its own stable, browsable log entry in RunWisp’s history. You’re
not SSHing in from CI, your shell scripts don’t live on the build
runner, and there’s one canonical “what happened” trail to look at
when things go sideways.
The task
Section titled “The task”[tasks.deploy-app]group = "Deploys"description = "Pull the new image, run migrations, restart workers"# No `cron` — manual / API trigger only.on_overlap = "terminate" # a fresh deploy preempts an in-flight onekeep_for = "180d" # six months of deploy historynotify_on_failure = ["slack-ops"]notify_on_success = ["slack-deploys"]
# timeout = "..." # see below — size to your worst deploy
run = """set -euo pipefailecho "[$(date -Iseconds)] starting deploy"
cd /srv/app
# Resolve the version to deploy. We pull whatever CI just pushed as# :next; the task itself decides what's current — there's no per-trigger# env-var injection over HTTP, so the source of truth is a registry tag# (or a file on disk) that CI updates before triggering this task.docker pull ghcr.io/example/app:nextVERSION=$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' ghcr.io/example/app:next)echo "deploying $VERSION"
# Schema migrations first — fail-fast if the new image is incompatible.docker run --rm \\ --env-file=/etc/app/migrate.env \\ ghcr.io/example/app:next \\ /usr/local/bin/migrate up
# Promote :next → :current and restart workers. compose detects the image change.docker tag ghcr.io/example/app:next ghcr.io/example/app:currentdocker compose up -d --no-deps app
# Smoke-test before declaring victory.sleep 5curl --silent --show-error --fail-with-body --max-time 10 \\ https://app.example.com/healthz
echo "[$(date -Iseconds)] deploy complete: $VERSION""""A few of the choices in there are worth pulling out:
No cron
Section titled “No cron”Leave cron off and the task becomes manual-only — nothing
fires it until something explicitly asks for a run.
Open the task and hit Run Now.
Focus deploy-app in the sidebar and press r.
POST /api/tasks/deploy-app/run — see Triggering from CI below for
the full CHAP handshake plus trigger script. On the box itself, runwisp exec deploy-app is the same call wrapped
in a CLI; from another machine, runwisp exec deploy-app --url … does it remotely.
runwisp list shows it as (manual) in the SCHEDULE column so
you can tell at a glance.
on_overlap = "terminate"
Section titled “on_overlap = "terminate"”If a deploy is still running when a fresh one comes in, kill the old one and start the new one. That’s what your team probably expects from a deploy anyway: the newest commit wins, nobody’s waiting for yesterday’s stuck migration to wrap up before they can ship a fix.
The default of "queue" would have you stacking deploys in a
serial line. "skip" would silently drop the new deploy on the
floor while the old one is still chugging.
"terminate" is
exactly right for this scenario, and almost nothing else.
notify_on_success = ["slack-deploys"]
Section titled “notify_on_success = ["slack-deploys"]”Deploys are the one place success notifications actually earn their keep. “v1.2.3 deployed at 14:32” is exactly the kind of update a team channel wants to see.
For pretty much everything else, success notifications are noise. The notifications model page goes into why per-task success notifications are opt-in.
Triggering from CI
Section titled “Triggering from CI”CHAP login
is a two-step dance. You GET a nonce, you POST back
sha256(password:nonce), and the daemon hands you a JWT.
set -euo pipefailBASE=https://runwisp.example.com
# 1. Get a one-shot nonce.NONCE=$(curl -sSf "$BASE/api/auth/challenge" | jq -r .nonce)
# 2. Compute the response and POST it back.RESP=$(printf '%s:%s' "$RUNWISP_PASSWORD" "$NONCE" | sha256sum | cut -d' ' -f1)TOKEN=$(curl -sSf -X POST "$BASE/api/auth" \\ -H 'Content-Type: application/json' \\ -d "$(jq -nc --arg n "$NONCE" --arg r "$RESP" '{nonce:$n, response:$r}')" \\ | jq -r .token)
# 3. Trigger the deploy.curl -sSf -X POST "$BASE/api/tasks/deploy-app/run" \\ -H "Authorization: Bearer $TOKEN"One thing worth knowing: the trigger endpoint does not accept
per-call env injection. There’s no way for CI to push a
DEPLOY_VERSION over HTTP into the task’s environment. You’ve got
two ways around that. Either have the task figure out the version
itself (which is what the example above does — it reads the
registry tag’s org.opencontainers.image.revision label), or have
CI write a file like /srv/app/current-version before triggering,
and have the task read it back. The TOML stays the source of truth
for what runs; HTTP only gets to say when.
Here’s what a typical GitHub Actions step looks like:
- name: Deploy to production env: RUNWISP_PASSWORD: ${{ secrets.RUNWISP_PASSWORD }} run: ./bin/runwisp-trigger.sh deploy-appWrite the CHAP dance once in bin/runwisp-trigger.sh and reuse it
across every deployable you’ve got.
Why use a RunWisp task instead of SSH-from-CI?
Section titled “Why use a RunWisp task instead of SSH-from-CI?”A perennial ops argument. Three reasons the RunWisp model wins for deploys:
- Audit trail. Every deploy lands as a row in the daemon with start/end timestamps, exit code, captured stdout/stderr, and a ULID you can quote in Slack. No more digging through GitHub Actions logs to figure out what actually ran on the host.
- No SSH keys for CI. Your pipeline talks to RunWisp over HTTPS with a token. The real production access — the ability to run shell on the box — stays scoped to the daemon’s user and nobody else.
- Anyone can re-run it. When something breaks at 3am, the on-call doesn’t need to wrangle a CI re-run. They open the Web UI or TUI and hit “Run Now” with the same setup that worked yesterday.
The trade-off is real: secrets the task needs (DB passwords, registry tokens) live on the RunWisp host’s filesystem now, not just ephemerally inside the pipeline. Make sure your data dir’s permissions are tight enough to deserve that trust.
A migration-only variant
Section titled “A migration-only variant”Sometimes you want to keep migrations and deploys separate — migrations during a maintenance window, the binary swap on its own schedule.
[tasks.migrate-app]group = "Deploys"description = "Run pending schema migrations"# No cron. Triggered from the maintenance dashboard (a wrapper script).on_overlap = "skip" # never two migrations at once — even by accidentkeep_for = "180d"notify_on_failure = ["slack-ops", "tg-oncall"]
# timeout = "..." # set above your longest migration if you want a # hard ceiling. A killed migration mid-statement is # dangerous — this is a last-resort guardrail, not a # fast-fail target.
run = """set -euo pipefaildocker run --rm --env-file=/etc/app/migrate.env \\ ghcr.io/example/app:current \\ /usr/local/bin/migrate up"""Notice that on_overlap is "skip" here, not "terminate" —
killing a migration partway through is genuinely dangerous. A
skipped manual trigger gets recorded as a skipped row (end
reason, not failed) with exit code -1 and the message “task
already running, skipping (policy: skip)” so you can see what got
ignored and why.