Skip to content

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.

[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 one
keep_for = "180d" # six months of deploy history
notify_on_failure = ["slack-ops"]
notify_on_success = ["slack-deploys"]
# timeout = "..." # see below — size to your worst deploy
run = """
set -euo pipefail
echo "[$(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:next
VERSION=$(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:current
docker compose up -d --no-deps app
# Smoke-test before declaring victory.
sleep 5
curl --silent --show-error --fail-with-body --max-time 10 \\
https://app.example.com/healthz
echo "[$(date -Iseconds)] deploy complete: $VERSION"
"""

The interesting choices:

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.

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.

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.

CHAP login is a two-step exchange: GET a nonce, POST back sha256(password:nonce), receive a JWT.

Terminal window
set -euo pipefail
BASE=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-app

Encapsulate the CHAP login dance in bin/runwisp-trigger.sh once, and reuse it across every deployable.

A running argument among ops folks. Three reasons RunWisp’s model wins for deploys:

  1. 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.
  2. 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.
  3. 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.

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 accident
keep_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 pipefail
docker 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.