Deploy hooks
A deploy hook is a manual-only task: no cron, fired from CI via
RunWisp’s REST API. Use it to give every deploy a stable, browsable
log entry — without invoking SSH from your pipeline, without keeping
shell scripts on the build runner, and with a single canonical
“what happened” trail in the daemon’s run history.
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""""The interesting choices:
No cron
Section titled “No cron”Omitting cron makes the task manual-only. It only runs when
something explicitly triggers it: a POST to
/api/tasks/deploy-app/run, the Web UI’s “Run Now” button, the
TUI’s r key, or runwisp run-task deploy-app from a shell on
the host (which calls the same REST endpoint).
runwisp list shows it as (manual) in the SCHEDULE column.
on_overlap = "terminate"
Section titled “on_overlap = "terminate"”If a deploy is still running when a fresh one arrives, kill the old one and start the new. This matches what your team probably expects from a deploy: the freshest commit wins; nobody waits for yesterday’s stuck migration to finish.
The default of "queue" would lock you into a serial queue of
deploys; "skip" would silently drop the new deploy on the floor
while old one chugs. "terminate" is right for this scenario and
almost no other.
notify_on_success = ["slack-deploys"]
Section titled “notify_on_success = ["slack-deploys"]”The only place we recommend success notifications. A deploy is a high-information event for a team channel — “v1.2.3 deployed at 14:32” is exactly the kind of thing channel members want.
For everything else, success notifications are noise. See the notifications model discussion of why per-task success notifications are opt-in.
Triggering from CI
Section titled “Triggering from CI”CHAP login is a two-step exchange: GET a nonce, POST back
sha256(password:nonce), receive 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"The trigger endpoint does not accept per-call env injection —
RunWisp does not currently let CI pass a DEPLOY_VERSION over HTTP
into the task’s environment. Either let the task resolve the version
itself (as the example does, by reading a registry tag’s
org.opencontainers.image.revision label), or have CI write a
/srv/app/current-version file before triggering and have the task
read it. The TOML is still the source of truth for what runs;
HTTP only decides when.
A typical GitHub Actions step:
- name: Deploy to production env: RUNWISP_PASSWORD: ${{ secrets.RUNWISP_PASSWORD }} run: ./bin/runwisp-trigger.sh deploy-appEncapsulate the CHAP login dance in bin/runwisp-trigger.sh once,
and reuse it across every deployable.
Why use a RunWisp task vs. SSH-from-CI?
Section titled “Why use a RunWisp task vs. SSH-from-CI?”A running argument among ops folks. Three reasons RunWisp’s model wins for deploys:
- Audit trail — every deploy is 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 find what actually happened on the host.
- No SSH keys for CI — your pipeline talks to RunWisp over HTTPS with a token. The actual production access — the ability to run shell on the host — stays scoped to the daemon’s user.
- Re-runnable from a human — when something goes wrong at 3am, the on-call doesn’t need to set up a CI rerun. They open the Web UI / TUI and press “Run Now” with the same arguments the last successful deploy used.
The downside is that secrets the task needs (DB passwords, registry tokens) live on the RunWisp host’s filesystem, not ephemerally in the pipeline. That’s a real trade-off — make sure your data dir’s permissions reflect it.
A migration-only variant
Section titled “A migration-only variant”Sometimes you want migrations and deploys decoupled: migrations during a maintenance window, the binary swap independently.
[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"""Note on_overlap = "skip" here, not terminate — partial
migrations are dangerous. A skipped manual trigger is recorded as
a skipped row (end reason, not failed) with exit code -1
and the message “task already running, skipping (policy: skip)”
so the operator can see they were ignored.
Where to next
Section titled “Where to next”[tasks.*]reference — every key on this task:on_overlap,notify_on_failure,keep_runs, etc.- Slack provider — wiring the
slack-opsandslack-deploysnotifiers. - Operations: auth — the CHAP flow your CI script is doing.