Skip to content

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.

[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"
"""

A few of the choices in there are worth pulling out:

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.

runwisp list shows it as (manual) in the SCHEDULE column so you can tell at a glance.

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.

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.

CHAP login is a two-step dance. You GET a nonce, you POST back sha256(password:nonce), and the daemon hands you 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"

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-app

Write 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:

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

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 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
"""

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.