Skip to content

Commit 89f0097

Browse files
authored
feat: add Docker backup and restore workflow (#233)
* feat: add Docker backup and restore workflow * docs: clarify PostgreSQL rollback command
1 parent c5aaf4e commit 89f0097

6 files changed

Lines changed: 1427 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Backup Contract
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
backup-contract:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
17+
- name: Run Docker-free backup contract test
18+
run: |
19+
bash -n deploy/backup.sh
20+
bash -n deploy/test-backup.sh
21+
bash deploy/test-backup.sh

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ We are especially interested in early bug reports and real-world feedback about
5959
- [Build instructions](BUILD.md)
6060
- [Contributing guide](CONTRIBUTING.md)
6161
- [Logging configuration](LOGGING.md)
62+
- [Docker backup and restore](deploy/backup-restore.md)
6263
- [Product documentation](https://windshift.sh/docs)
6364

6465
## License

deploy/backup-restore.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Backing up and restoring Windshift
2+
3+
`backup.sh` is a conservative host-side workflow for the stock Docker Compose deployments in this repository.
4+
It is not a general-purpose Docker or database recovery tool.
5+
It backs up the complete configured `/data` tree and, for the supported PostgreSQL shape, a custom-format logical dump.
6+
7+
The helper image is pinned to the Alpine digest used by `deploy/coding-agent/Dockerfile`.
8+
It has no network, a read-only root filesystem, and no-new-privileges.
9+
The backup helper receives Windshift volumes read-only.
10+
The restore helper receives a writable data volume only after all preflight checks and a local rollback snapshot have completed.
11+
12+
## Supported scope
13+
14+
The workflow requires exactly one Compose `windshift` container using the stock `/windshift` entrypoint and exactly one persistent mount at the configured data path.
15+
Additional mounts below that path are rejected because deleting a mounted child can leave both restore and rollback incomplete.
16+
The backup directory and rollback root must be outside that mount's host source so an archive cannot include or delete itself.
17+
Replicas are rejected.
18+
It derives a stable, domain-separated hash from the Compose project and service labels and uses a system-wide mode-700 lock directory under `/var/tmp`.
19+
20+
SQLite is supported only when `DB_PATH` is below `/data`.
21+
An empty `DB_PATH` is rejected because Windshift would otherwise fall back to a path outside the mounted data tree.
22+
The database path is recorded in the manifest and must exist as a nonempty regular archive entry.
23+
The script also checks `ATTACHMENT_PATH`, `PLUGIN_DIR`, `AI_PROMPTS_DIR`, `LLM_PROVIDERS_FILE`, and trimmed comma-separated `PLUGIN_DIRS` when configured.
24+
An empty `PLUGIN_DIR` is rejected because Windshift would otherwise fall back to a relative path outside the data mount.
25+
SSH host keys and SSH-enabled deployments are outside this workflow because the image-relative default path is not guaranteed to be under `/data`.
26+
Any extra host mounts or persisted paths outside `/data` are out of scope and cause a safe failure when they are configured through these variables.
27+
28+
PostgreSQL is supported only when all of the following are true:
29+
30+
- `DB_TYPE=postgres` and `POSTGRES_CONNECTION_STRING` is unset.
31+
- `POSTGRES_HOST` exactly equals the selected Compose service.
32+
- `POSTGRES_PORT` is unset or 5432.
33+
- `POSTGRES_USER` and `POSTGRES_DB` exactly match that service's container environment.
34+
- Exactly one PostgreSQL service container exists.
35+
36+
The automatic service candidates are `postgres` and `db`.
37+
Use `--postgres-service NAME` for another stock-style service.
38+
On restore, that explicit option overrides the manifest value.
39+
External databases, connection strings, non-default ports, custom database selection, and replicas are deliberately unsupported.
40+
Any command-line use of `-postgres-connection-string`, `--postgres-connection-string`, `-pg-conn`, or `--pg-conn` is also out of scope and fails closed.
41+
The path override flags `-db`, `--db`, `-attachment-path`, `--attachment-path`, `-llm-providers`, `--llm-providers`, `-ai-prompts-dir`, and `--ai-prompts-dir` are out of scope too.
42+
Environment values containing CR or LF are rejected because this workflow cannot safely fingerprint or archive them.
43+
Use a database-specific procedure for those deployments.
44+
45+
Use the same PostgreSQL major version for backup and restore.
46+
Restore only into the same Windshift image and schema generation that produced the backup unless a separately verified migration plan says otherwise.
47+
48+
## Create a backup
49+
50+
Run from the Compose directory:
51+
52+
cd deploy
53+
54+
./backup.sh backup /srv/windshift-backups/windshift-$(date -u +%Y%m%dT%H%M%SZ)
55+
56+
The backup path must be an absolute, normalized host path without a colon because it is passed to Docker's `-v` syntax.
57+
The created directory is mode 700.
58+
It contains `data.tar.gz`, `manifest.env`, and `checksums.sha256`.
59+
PostgreSQL backups also contain `database.dump`.
60+
61+
The script takes an atomic lock before stopping the app or creating backup files.
62+
If a lock exists, do not run another operation.
63+
First confirm that no backup or restore is running, then remove only the reported stale lock directory.
64+
65+
SHA-256 checksums detect accidental corruption and incomplete copies.
66+
They do not authenticate a backup.
67+
Use access-controlled, authenticated, encrypted storage and transport.
68+
69+
The manifest stores a domain-separated SHA-256 fingerprint of the effective `SSO_SECRET`, or `SESSION_SECRET` when no `SSO_SECRET` is present.
70+
This is a compatibility check, not a password verifier.
71+
Weak secrets therefore still produce easily guessable fingerprints.
72+
73+
Pass `--include-sso-secret` only for an explicitly encrypted secret-recovery copy.
74+
It writes the raw effective secret as mode-600 `sso-secret` inside the mode-700 backup and includes it in the checksum file.
75+
This artifact is highly sensitive and must never be logged or copied to ordinary backup media.
76+
77+
## Restore
78+
79+
Run from the same Compose directory:
80+
81+
cd deploy
82+
83+
./backup.sh restore --force /srv/windshift-backups/windshift-20260829T120000Z
84+
85+
Before stopping Windshift, restore rejects symlinked or non-regular input artifacts, copies approved artifacts into a private staging directory, and verifies only that staged snapshot thereafter.
86+
The helper checks the staged archive in the same image that performs extraction.
87+
It accepts only regular files and directories, rejects absolute and parent paths, and rejects symbolic links, hard links, devices, and FIFOs.
88+
89+
Restore compares the live secret fingerprint before any state change.
90+
On mismatch it fails closed and never writes a secret into Compose.
91+
Configure the source secret in Compose or the secret manager first.
92+
93+
After the app stops, the script creates a mode-700 rollback directory under `--rollback-root` or `WINDSHIFT_BACKUP_ROLLBACK_ROOT`.
94+
The default is disk-backed `/var/tmp`.
95+
That directory must already exist and be writable.
96+
Choose a local, private filesystem with space for a full compressed `/data` copy, staging copy, and, for PostgreSQL, a second logical dump.
97+
Do not put it on volatile `/tmp` unless its loss during reboot is acceptable.
98+
99+
For PostgreSQL the rollback contains both `/data` and a pre-restore database dump.
100+
The script writes SHA-256 checksums for retained rollback artifacts and structurally validates custom-format PostgreSQL dumps with `pg_restore --list` before relying on them.
101+
The target database restore uses `pg_restore --single-transaction --exit-on-error`.
102+
It cleans and restores archive objects, not necessarily unrelated database objects.
103+
104+
The rollback is retained until the started application is healthy.
105+
When Docker exposes a health check, the script waits for it for up to `WINDSHIFT_BACKUP_HEALTH_TIMEOUT` seconds, default 90.
106+
Without a Docker health status, it executes `/windshift healthcheck` through Compose until the configured deadline.
107+
Increase `WINDSHIFT_BACKUP_HEALTH_TIMEOUT` before restore when startup or migrations can legitimately take longer than 90 seconds.
108+
If the target is unhealthy, it restores both rollback data and PostgreSQL before trying the old state.
109+
If PostgreSQL state is uncertain because of an interruption or failed database command, Windshift remains stopped and the error retains both rollback and staged paths.
110+
The recovery lock also remains in place because a detached server-side `pg_restore` may still be running.
111+
Remove that lock only after confirming that no backup, restore, or PostgreSQL restore process is active and after resolving the database state.
112+
If Windshift was already stopped before restore, the script cannot perform a health check or automatic start.
113+
It reports success with the retained rollback path so the operator can validate a manual start before removing it.
114+
115+
The command uses a 60-second Compose stop timeout.
116+
An interrupted command, host reboot, or Docker failure can still leave the service stopped.
117+
Read the printed recovery paths before restarting it.
118+
On a confirmed healthy restore, cleanup failures only warn and retain the private rollback or stage instead of changing the restored state.
119+
The ephemeral backup helper streams its tar archive to a host-created mode-600 file, so it does not need to chown host files.
120+
The Docker-free contract test does not prove behavior against a real Docker daemon, image, filesystem driver, or PostgreSQL server.
121+
122+
## Manual emergency recovery
123+
124+
This is a destructive emergency procedure.
125+
Leave Windshift stopped and first copy every reported stage and rollback directory to immutable, access-controlled storage.
126+
Use only the same Windshift image/schema generation and the same PostgreSQL major version.
127+
128+
Before stopping or deleting anything, validate the retained checksums and data archive in a separate preflight.
129+
Use the SHA-256 tool available on the host and the exact pinned helper image shown below:
130+
131+
cd deploy
132+
(cd "$ROLLBACK_DIR" && sha256sum -c checksums.sha256)
133+
docker run --rm --network none --read-only --security-opt no-new-privileges -v "$ROLLBACK_DIR:/backup:ro" alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d sh -ec '
134+
test -s /backup/data.tar.gz
135+
tar -tzf /backup/data.tar.gz >/dev/null
136+
tar -tvzf /backup/data.tar.gz >/dev/null
137+
tar -tzf /backup/data.tar.gz | awk '\''{
138+
path = $0
139+
if (path == "./") key = "."
140+
else {
141+
if (substr(path, 1, 2) != "./") exit 1
142+
path = substr(path, 3)
143+
if (path == "" || path ~ /(^|\/)\.($|\/)/ || path ~ /(^|\/)\.\.($|\/)/ || path ~ /\/\//) exit 1
144+
sub(/\/$/, "", path)
145+
if (path == "") exit 1
146+
key = path
147+
}
148+
if (seen[key]++) exit 1
149+
count++
150+
} END { if (!count) exit 1 }'\''
151+
if tar -tvzf /backup/data.tar.gz | grep -Eqv "^[-d]"; then exit 1; fi
152+
if tar -tvzf /backup/data.tar.gz | grep -E "^-.* -> "; then exit 1; fi
153+
'
154+
155+
If the host provides `shasum` instead, replace the checksum command with `shasum -a 256 -c checksums.sha256`.
156+
Do not continue unless every command succeeds.
157+
158+
For PostgreSQL, validate the retained database dump before changing `/data`.
159+
Set `POSTGRES_SERVICE` to the confirmed service name and keep `remote_dump` for the restore step below:
160+
161+
POSTGRES_SERVICE=postgres
162+
db_id=$(docker compose ps --all -q "$POSTGRES_SERVICE")
163+
[ "$(printf '%s\n' "$db_id" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 ] || exit 1
164+
remote_dump=/tmp/windshift-manual-rollback-$$.dump
165+
docker cp "$ROLLBACK_DIR/database-before.dump" "$db_id:$remote_dump"
166+
docker compose exec -T -e REMOTE_DUMP="$remote_dump" "$POSTGRES_SERVICE" sh -ec 'pg_restore --list "$REMOTE_DUMP" >/dev/null'
167+
168+
For `/data`, resolve exactly one Windshift container before stopping it:
169+
170+
cd deploy
171+
windshift_id=$(docker compose ps --all -q windshift)
172+
[ "$(printf '%s\n' "$windshift_id" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 ] || exit 1
173+
mount_destinations=$(docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$windshift_id")
174+
[ "$(printf '%s\n' "$mount_destinations" | grep -Fxc /data)" = 1 ] || exit 1
175+
if printf '%s\n' "$mount_destinations" | grep -Eq '^/data/'; then exit 1; fi
176+
docker compose stop --timeout 60 windshift || exit 1
177+
[ "$(docker compose ps --all -q windshift)" = "$windshift_id" ] || exit 1
178+
[ "$(docker inspect -f '{{.State.Running}}' "$windshift_id")" = false ] || exit 1
179+
docker run --rm --network none --read-only --security-opt no-new-privileges --volumes-from "$windshift_id" -v "$ROLLBACK_DIR:/backup:ro" alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d sh -ec 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +; tar -C /data -xzf /backup/data.tar.gz'
180+
181+
For PostgreSQL, restore the already validated remote dump only after confirming the target database again:
182+
183+
docker compose exec -T -e REMOTE_DUMP="$remote_dump" "$POSTGRES_SERVICE" sh -ec 'pg_restore --clean --if-exists --no-owner --no-privileges --single-transaction --exit-on-error -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$REMOTE_DUMP"'
184+
docker compose exec -T "$POSTGRES_SERVICE" rm -f "$remote_dump"
185+
186+
The PostgreSQL rollback cleans objects listed in its dump but may leave unrelated objects created by a newer schema.
187+
Remove any container path printed as `remote=` after the database state has been recovered and the retained dump has been preserved elsewhere.
188+
Only then start Windshift and run its health check.

0 commit comments

Comments
 (0)