deploy/update.sh updates a Compose deployment of WindsurfAPI in one command:
bash deploy/update.shIt pulls the new image, recreates only the windsurf-api service, waits for
GET /health, and automatically restores the image that was running before if
anything fails. The data volume is never touched. No Watchtower, no Compose
profile, and no Docker socket mount are required — the script runs on the host
with the same Docker CLI you already use.
First install (once):
cp .env.example .env # then edit API_KEY / DASHBOARD_PASSWORD
docker compose up -d # or: docker compose up -d --buildEvery update after that:
bash deploy/update.shCommon variants:
bash deploy/update.sh --tag 3.9.52 # pin a release tag (leading "v" is accepted)
bash deploy/update.sh --image ghcr.io/you/windsurf-api:latest # fork / custom ref
bash deploy/update.sh --build # rebuild from this checkout instead of pulling
bash deploy/update.sh --dry-run # preflight + print the plan; change nothing
bash deploy/update.sh --rollback # restore the saved rollback pointRun bash deploy/update.sh --help for the full flag list.
- Preflight —
docker composev2 is present, the daemon is reachable, and the compose file validates. - Lock —
.docker-data/update/lock/serializes updaters. A stale lock is recovered only when its owner PID is provably gone. - Capture — records the running container's image ID and image reference in
.docker-data/update/rollback-point.env, and tags the old image asghcr.io/dwgx/windsurf-api:rollback. - Fetch —
docker compose pull windsurf-api(default) ordocker compose build windsurf-api(--build). Pull/build failures exit before the running container is touched. - Recreate —
docker compose up -d --no-deps --no-build windsurf-api. Only the app service is recreated; nginx keeps running. - Health gate — polls
/healthuntil it returns HTTP 200 with"status":"ok", or the timeout expires (default 120 s,--timeout N). The probe goes through the published nginx port (docker compose port nginx 80), falls back to the app port, and finally todocker compose execinside the container. - Rollback on failure — retags the previous image back to its original
reference, runs
docker compose up -d --no-deps --no-build --force-recreate windsurf-api, and re-runs the health gate.
On success the script writes .docker-data/update/last-success.env (image ID,
reference, timestamp, reported version) for diagnostics.
The script never runs any of these:
docker compose down
docker compose up -v / --volumes
docker volume rm
docker rm
rm -rf
Compose reuses the bind mounts from docker-compose.yml:
| Host path | Container path | Holds |
|---|---|---|
./.docker-data/data |
/data |
accounts.json, proxy.json, stats.json, runtime-config.json, model-access.json, logs/ |
./.docker-data/opt/windsurf |
/opt/windsurf |
Language Server binary + data |
./.docker-data/tmp/windsurf-workspace |
/tmp/windsurf-workspace |
temporary workspace |
The rollback point lives in ./.docker-data/update/, which is not mounted into
the container. docker compose up recreates containers but keeps volumes and
bind mounts; only a manual docker compose down -v (or docker volume rm)
destroys data. Do not run those during an update.
| Code | Meaning |
|---|---|
0 |
Update succeeded and /health passed. --dry-run preflight also exits 0. |
1 |
Update failed. The previous image is running again and healthy, or the pull/build failed before anything was recreated. |
2 |
Update failed and the rollback failed (or there was no previous image). Manual intervention required; data is still untouched. |
docker-compose.yml now resolves the app image through a variable:
image: ${WINDSURFAPI_IMAGE:-ghcr.io/dwgx/windsurf-api:latest}So all of these work:
bash deploy/update.sh --tag 3.9.52
WINDSURFAPI_IMAGE=ghcr.io/you/windsurf-api:latest bash deploy/update.sh
# or put WINDSURFAPI_IMAGE=... in .envThe script exports WINDSURFAPI_IMAGE for its own Compose calls, so --tag
works even when .env pins something else. The registry is whatever docker
is already logged in to; the script never reads, writes, or prints credentials.
If the GHCR package is private, run docker login ghcr.io once on the host.
All optional; the defaults match the checked-in docker-compose.yml.
| Variable | Default | Purpose |
|---|---|---|
WINDSURFAPI_IMAGE |
compose default | Image reference; also read by docker-compose.yml. |
WINDSURFAPI_COMPOSE_FILE |
<repo>/docker-compose.yml |
Compose file to update. |
WINDSURFAPI_PROJECT_DIR |
<repo> |
Compose project directory (resolves relative bind mounts). |
WINDSURFAPI_SERVICE |
windsurf-api |
App service name. |
WINDSURFAPI_LB_SERVICE |
nginx |
Service whose published port is used for /health. |
WINDSURFAPI_UPDATE_STATE_DIR |
<repo>/.docker-data/update |
Rollback point + lock. |
WINDSURFAPI_HEALTH_TIMEOUT |
120 |
Health gate seconds (--timeout wins). |
WINDSURFAPI_HEALTH_INTERVAL |
2 |
Seconds between probes. |
WINDSURFAPI_DOCKER |
docker |
Docker binary. |
COMPOSE_PROJECT_NAME |
directory name | Compose project; honored by both Compose and the script. |
Use --build when the image is built locally or the tag is not published:
bash deploy/update.sh --buildThe script fills the Dockerfile's BUILD_VERSION, BUILD_COMMIT,
BUILD_BRANCH, BUILD_COMMIT_DATE and BUILD_COMMIT_MESSAGE build args from
package.json / git when they are not already set, so /health reports the
commit that is actually running. Rollback still works: the previous image ID is
kept and retagged.
If a plain pull fails with manifest unknown / not found, the tag is not in
the registry — retry with --build (the script prints this hint too).
/health is served by the app and proxied by the bundled nginx. A successful
gate requires HTTP 200 and "status":"ok" in the body; a 200 HTML error
page or a 502 from nginx does not pass. The script also fails fast if the app
container is exited or dead.
After the app container is recreated, nginx's resolver ... valid=10s can serve
a brief 502 until it re-resolves the new container IP. The polling loop absorbs
that window.
Tune it with:
bash deploy/update.sh --timeout 300Restore the image that was running before the most recent update:
bash deploy/update.sh --rollbackIf the automatic rollback also failed, recover by hand:
docker compose ps
docker compose logs --tail=200 windsurf-api
# Retag the saved image and recreate only the app
docker tag <old-image-id> ghcr.io/dwgx/windsurf-api:latest
WINDSURFAPI_IMAGE=ghcr.io/dwgx/windsurf-api:latest \
docker compose up -d --no-deps --force-recreate windsurf-api
curl -fsS http://127.0.0.1:3003/healthThe previous image ID is in .docker-data/update/rollback-point.env and is also
tagged ghcr.io/dwgx/windsurf-api:rollback. To go back to a specific published
release instead, run bash deploy/update.sh --tag <version>.
A stale lock after a killed run:
cat .docker-data/update/lock/pid # confirm the PID is not running
rmdir .docker-data/update/lock # only when the owner is goneThe script performs the same check automatically on the next run.
The dashboard's "check for update" button can also update a Compose deployment,
but only when /var/run/docker.sock is mounted into the container. That mount
is effectively root on the host, which is why it is commented out by default in
docker-compose.yml.
The dashboard path is now health-gated like this script and trusts only a digest, never a tag:
- It resolves the running tag through the daemon's
GET /distribution/<repo>:<tag>/jsonendpoint (no layer download) and requires the result to equal the digest in<DATA_DIR>/image-digest.txt. Missing or mismatched verification material refuses the update before any pull or container create. Downloadimage-digest.txtfrom the matching GitHub Release (and verify its.sigstore.jsonbundle withcosign verify-blob) and place it in the data directory first; a digest-pinnedWINDSURFAPI_IMAGE=repo@sha256:…is an explicit operator pin. - Before any pull or recreate it records the running image ID, the rollback tag
and the Compose project/service in
<DATA_DIR>/self-update-docker.json. If that record cannot be written, the update is refused. - The app image is pulled by digest; the
docker:24-clideployer is pinned by digest and everydocker compose upuses--no-build, so Compose can never build from the on-disk checkout. - The deployer sidecar recreates the stack, polls
/healthinside the app container, and on failure restores the previous image as<repo>:rollbackand recreates the app service from it. - The deployer container is not auto-removed: its exit code is the durable
outcome (0 healthy · 1 rolled back healthy · 2 rollback failed · 3 recreate
failed) until the next
/self-update/checkrecords it in the journal and removes the container.
POST /dashboard/api/self-update therefore returns
{ ok: true, state: "pending-recreate", verified: false, rollbackPoint: … }:
ok means the deployer was started, not that the new container is healthy.
Success is only reported after the restart by
GET /dashboard/api/self-update/check →
dockerSelfUpdate.state === "confirmed" (or rolled-back, rollback-failed,
recreate-failed, unverified). A deployer failure is no longer
indistinguishable from a verified rollback.
Do not trigger both at the same time: the script's lock only serializes script runs, not the dashboard's deployer sidecar.
Both update paths write a durable journal before mutating and keep their rollback point until the restarted process proves it serves the expected commit/image.
| Path | Journal | Confirmed by | Retry evidence |
|---|---|---|---|
git (POST /self-update, /self-update/rollback) |
data/self-update-journal.json |
/self-update/check → selfUpdate.state === "confirmed" when the running commit matches |
data/self-update-before.json is retained until confirmed |
docker (POST /self-update when git is unavailable) |
<DATA_DIR>/self-update-docker.json |
/self-update/check → dockerSelfUpdate.state === "confirmed" from the deployer exit code |
previous image ID + <repo>:rollback tag |
A rollback whose restart cannot be scheduled returns
{ ok: false, error: "ERR_RESTART_NOT_SCHEDULED", state: "restart-not-scheduled", rollbackPointRetained: true } — the reset is on disk, but nothing will relaunch
the gateway, so it is not a successful rollback. Retry after fixing the
supervisor, or restart the process manually.
No daemon, no containers, no secrets:
# Print the resolved plan and validate compose/daemon access
bash deploy/update.sh --dry-run
# Exercise pull/build → recreate → health → rollback against fake docker/curl
node --test test/docker-oneclick-update.test.js
# same suite through the npm alias
npm run smoke:docker-updateThe integration cases in the Node test run on Linux/macOS; on Windows they are
skipped (Windows cannot exec the fake #!/usr/bin/env bash shims from PATH),
while the static contract checks still run.
For a real end-to-end check, run against a disposable Compose project in its own
directory so it cannot share .docker-data with the real deployment:
tmp="$(mktemp -d)"
cp docker-compose.yml nginx.conf "$tmp"/
[ -f .env ] && cp .env "$tmp"/ || true
COMPOSE_PROJECT_NAME=windsurfapi-smoke PORT=3999 \
docker compose -f "$tmp/docker-compose.yml" up -d
COMPOSE_PROJECT_NAME=windsurfapi-smoke PORT=3999 \
WINDSURFAPI_COMPOSE_FILE="$tmp/docker-compose.yml" \
WINDSURFAPI_PROJECT_DIR="$tmp" \
WINDSURFAPI_UPDATE_STATE_DIR="$tmp/update" \
bash deploy/update.sh
COMPOSE_PROJECT_NAME=windsurfapi-smoke \
docker compose -f "$tmp/docker-compose.yml" down # no -v: keeps its dataWINDSURFAPI_PROJECT_DIR makes relative bind mounts resolve inside $tmp
instead of the real checkout. Use --dry-run first if you only want to validate
the wiring.
| Symptom | Cause / fix |
|---|---|
docker compose v2 is required |
Install the Compose v2 plugin (docker compose version), not legacy docker-compose. |
cannot reach the Docker daemon |
Start Docker, or add your user to the docker group (Linux). |
image pull failed |
Check docker login ghcr.io, the tag name, and network access. The running container was not touched. |
manifest unknown on pull |
The tag is not published; use --build or a published tag. |
/health did not pass |
The script already rolled back. Inspect docker compose logs --tail=200 windsurf-api and .docker-data/update/. |
rollback also failed |
Use the manual recovery block above; volumes are intact. |
another update is already running |
Wait, or remove a stale lock after confirming the PID is gone. |
| Update succeeded but the version is old | docker compose pull may have hit a cached tag; check docker image inspect, then retry with an explicit --tag. |
- No credentials,
.envcontents, account data, or API keys are read or logged by the script. Registry auth stays indocker's credential store. - No Docker socket is mounted, no privileged container is created, and no Watchtower-style agent is installed.
- State files contain only image IDs, image references, timestamps, and the
version string reported by
/health. - The script runs with the invoking user's Docker permissions; anyone who can run it can already control the Docker daemon.