# One-command Docker updates `deploy/update.sh` updates a Compose deployment of WindsurfAPI in one command: ```bash bash deploy/update.sh ``` It 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. ## Quick start First install (once): ```bash cp .env.example .env # then edit API_KEY / DASHBOARD_PASSWORD docker compose up -d # or: docker compose up -d --build ``` Every update after that: ```bash bash deploy/update.sh ``` Common variants: ```bash 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 point ``` Run `bash deploy/update.sh --help` for the full flag list. ## What the script does 1. **Preflight** — `docker compose` v2 is present, the daemon is reachable, and the compose file validates. 2. **Lock** — `.docker-data/update/lock/` serializes updaters. A stale lock is recovered only when its owner PID is provably gone. 3. **Capture** — records the running container's image ID and image reference in `.docker-data/update/rollback-point.env`, and tags the old image as `ghcr.io/dwgx/windsurf-api:rollback`. 4. **Fetch** — `docker compose pull windsurf-api` (default) or `docker compose build windsurf-api` (`--build`). Pull/build failures exit before the running container is touched. 5. **Recreate** — `docker compose up -d --no-deps --no-build windsurf-api`. Only the app service is recreated; nginx keeps running. 6. **Health gate** — polls `/health` until 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 to `docker compose exec` inside the container. 7. **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. ## Data safety The script never runs any of these: ```text 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. ## Exit codes | 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. | ## Pinning and image overrides `docker-compose.yml` now resolves the app image through a variable: ```yaml image: ${WINDSURFAPI_IMAGE:-ghcr.io/dwgx/windsurf-api:latest} ``` So all of these work: ```bash 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 .env ``` The 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. ## Environment overrides 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` | `/docker-compose.yml` | Compose file to update. | | `WINDSURFAPI_PROJECT_DIR` | `` | 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` | `/.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. | ## Local builds and forks Use `--build` when the image is built locally or the tag is not published: ```bash bash deploy/update.sh --build ``` The 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 gate details `/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 bash deploy/update.sh --timeout 300 ``` ## Manual rollback and recovery Restore the image that was running before the most recent update: ```bash bash deploy/update.sh --rollback ``` If the automatic rollback also failed, recover by hand: ```bash docker compose ps docker compose logs --tail=200 windsurf-api # Retag the saved image and recreate only the app docker tag 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/health ``` The 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 `. A stale lock after a killed run: ```bash cat .docker-data/update/lock/pid # confirm the PID is not running rmdir .docker-data/update/lock # only when the owner is gone ``` The script performs the same check automatically on the next run. ## In-dashboard update vs this script 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 host-side script needs no socket mount and adds the health gate + automatic rollback. Do not trigger both at the same time: the script's lock only serializes script runs, not the dashboard's deployer sidecar. ## Smoke tests No daemon, no containers, no secrets: ```bash # 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-update ``` The 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: ```bash 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 data ``` `WINDSURFAPI_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. ## Troubleshooting | 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`. | ## Security notes - No credentials, `.env` contents, account data, or API keys are read or logged by the script. Registry auth stays in `docker`'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.