Make Backspace self-hostable in any homelab environment, not just a clean host
that owns ports 80/443.
install.sh is now mode-aware and auto-detects which fits:
- allinone (default): bundled Caddy + auto-HTTPS — unchanged behavior
- proxy: behind your own reverse proxy (nginx / Traefik / Caddy / Nginx Proxy
Manager / SWAG) — app published on 127.0.0.1:APP_PORT, no bundled Caddy,
prints paste-ready proxy snippets
- tunnel: behind a tunnel (Cloudflare / Tailscale) — same, plus a 90MB upload
cap (under Cloudflare's 100MB body limit) and voice force-disabled (WebRTC
over UDP can't traverse a tunnel)
Port detection is Docker-aware (consults `docker ps` published ports, not just
`ss`), so a host whose proxy already owns 80/443 via iptables DNAT — with no
listening socket for `ss` to see — is correctly detected as "taken" instead of
dead-ending.
docker-compose.proxy.yml is a small overlay, layered via COMPOSE_FILE (written
into .env so no `-f` flags are ever needed), that publishes the loopback port and
parks Caddy in an inert profile. The base compose file is untouched, so All-in-One
behaves exactly as before.
Prebuilt image: .github/workflows/docker-publish.yml builds and pushes a
multi-arch (linux/amd64 + linux/arm64) image to ghcr.io/thezwiss/backspace on
release tags (and manual dispatch), so weak/ARM hosts skip the ~1.6GB local build
(the Vite build OOMs small ARM boxes). install.sh and docker-compose.yml default
to pulling it, fall back to an image already present on the host, and finally to a
from-source build — AGPL §13 commit stamping preserved on every path. Kept
deliberately separate from the desktop-installer workflow (release.yml).
Docs: README gains a "Deployment modes" section (all three modes, nginx / Caddy /
Traefik snippets, GUI-proxy field-by-field, cloudflared ingress, the update path,
and voice-per-mode caveats); docs/systems/deployment.md updated to match.
Verified live on a throwaway VM: proxy + all-in-one end-to-end through install.sh
(with a real Let's Encrypt cert), tunnel config generation, loopback-only binding,
and the local-image fallback path.
30 KiB
Deployment & Operations
Operator- and contributor-facing reference for hosting Backspace: the Docker build pipeline, admin bootstrap, database backup/restore, image pinning, and the relevant environment variables.
Source files:
Dockerfile-- multi-stage build (builder → runtime)docker-compose.yml-- base stack:backspace+caddy(+ optionallivekit) services, healthcheckdocker-compose.proxy.yml-- proxy/tunnel overlay: publishes the app on127.0.0.1:APP_PORTand drops Caddy.github/workflows/docker-publish.yml-- multi-arch (amd64+arm64) GHCR image publishCaddyfile-- reverse proxy / auto-HTTPS config (All-in-One mode only)install.sh-- interactive first-time setup, mode-aware (allinone / proxy / tunnel)deploy.sh-- rsync + rebuild to Heidi's two boxesbackup.sh/restore.sh-- manual snapshot + restore tooling (host side)packages/server/src/config.ts--config.backup.*env parsingpackages/server/src/utils/backup.ts--createSnapshot(VACUUM INTO),listSnapshots,pruneSnapshots, off-box hookpackages/server/src/utils/backupWorker.ts-- scheduled-snapshot interval workerpackages/server/src/db/index.ts-- pre-migration snapshot trigger + WAL-checkpointing shutdownpackages/server/src/db/pendingMigrations.ts--hasPendingMigrations(gating predicate)packages/server/src/db/migrate.ts--ensureDefaults(admin recovery net)packages/server/src/routes/auth.ts-- first-user-becomes-admin bootstrappackages/server/src/scripts/snapshot.ts-- manual snapshot CLI entrypointpackages/server/src/scripts/remediate-seed-admin.ts-- legacy seed-admin password rotation
Out of scope: voice/LiveKit operational tuning (see docs/systems/voice.md), upload/storage janitor (see docs/systems/uploads.md), and federation peering/replication (see docs/systems/federation.md).
1. Pipeline Overview
Backspace ships as a single application container. In the default All-in-One deployment it is fronted by the bundled Caddy (automatic HTTPS); behind an operator's own reverse proxy or a tunnel, Caddy is dropped and the container is published on a host loopback port instead (see Deployment modes below). The application image is a prebuilt multi-architecture image published to GHCR — docker compose pull (install.sh's default path) fetches ghcr.io/thezwiss/backspace for linux/amd64 or linux/arm64, so weak/ARM hosts skip the heavy local build; a from-source build is the fallback when the image can't be pulled.
Prebuilt image (GHCR)
.github/workflows/docker-publish.yml builds and pushes the application image to ghcr.io/thezwiss/backspace on every v* tag (and on manual workflow_dispatch). It is deliberately separate from the desktop-installer workflow (release.yml): the two share the v* tag trigger but build entirely different artifacts and must not be entangled.
- Multi-arch.
docker/setup-qemu-action+buildxbuildlinux/amd64,linux/arm64in one push, so a Raspberry Pi pulls a native image instead of cross-building (the Vite build OOMs small ARM boxes). - Tags.
docker/metadata-actionderives{version},{major}.{minor},latest(onv*tags), andsha-<short>. Aworkflow_dispatchwith an extrataginput publishes that tag too (e.g.latestwithout cutting a release). - AGPL § 13 commit stamping is preserved. The workflow resolves
git rev-parse --short HEADand passes it as--build-arg BACKSPACE_COMMIT=…, exactly likeinstall.sh/deploy.sh, plus OCI labels (source,licenses=AGPL-3.0-only,revision). The pulled image therefore advertises its exact source version viaGET /api/instance/info. - Auth. The push authenticates with the built-in
GITHUB_TOKEN(permissions: packages: write). The GHCR package must be set public once for unauthenticateddocker pullto work. - Compose wiring.
docker-compose.ymldeclares bothimage: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}andbuild: ..pull/upuses the image;up --build(deploy.sh, or install.sh's fallback) builds from source and tags the result under the same ref. Operators pin a version or point at a fork's registry viaBACKSPACE_IMAGE/BACKSPACE_IMAGE_TAG.
Deployment modes
One installer, three modes, recorded as DEPLOY_MODE in .env. install.sh auto-detects (and, when ambiguous, prompts); a non-interactive run honors an explicit DEPLOY_MODE.
| Mode | Ports 80/443 | Topology | TLS | Voice |
|---|---|---|---|---|
allinone (default) |
must be free | base docker-compose.yml: backspace + caddy (+ livekit) |
bundled Caddy (Let's Encrypt) | ✅ with UDP media ports open |
proxy |
already taken | base + docker-compose.proxy.yml: backspace on 127.0.0.1:APP_PORT, no Caddy |
operator's reverse proxy | ✅ if operator proxies /livekit and opens media ports |
tunnel |
already taken | same overlay as proxy |
tunnel provider (Cloudflare, Tailscale…) | ❌ WebRTC/UDP can't traverse a tunnel |
The overlay (docker-compose.proxy.yml). Layered on top of the base file it (1) publishes backspace on 127.0.0.1:${APP_PORT:-8080}:${PORT:-3000} — loopback only, so nothing is exposed on a public interface — and (2) parks caddy in an inert profile (_proxy_mode_no_caddy) that is never activated, so Caddy does not start. The base file is unchanged, so All-in-One (docker-compose.yml alone) behaves exactly as before.
COMPOSE_FILE wiring. In proxy/tunnel mode install.sh writes COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml into .env. Docker Compose reads COMPOSE_FILE from .env, so every later docker compose … command in the directory transparently uses both files — the operator (and the update commands) never need -f flags. All-in-One leaves COMPOSE_FILE unset (defaults to docker-compose.yml).
Server proxy-awareness. The server sets Fastify trustProxy: true, so it trusts X-Forwarded-* from the fronting proxy/tunnel. getOurOrigin() returns https://${DOMAIN} (federation/public identity) whenever DOMAIN is set and PUBLIC_ORIGIN is unset — correct in all three modes, since the public URL is https://DOMAIN regardless of which layer terminates TLS. No PUBLIC_ORIGIN is needed for a normal proxy/tunnel deployment.
Voice per mode. LiveKit media is WebRTC over UDP and never flows through the HTTP proxy/tunnel — the media ports (3478/udp TURN, 7881/tcp fallback, 50000-60000/udp media) must be reachable from clients directly. All-in-One proxies LiveKit signaling through Caddy (/livekit → host.docker.internal:7880); a reverse-proxy operator must replicate that route (/livekit → 127.0.0.1:7880, prefix stripped) and open the media ports. Over a tunnel, voice is unavailable and install.sh force-disables it. See docs/systems/voice.md for LiveKit tuning.
Build: multi-stage Dockerfile
Dockerfile has two stages:
builder(node:20-slim) — enables pnpm via corepack, installs the full workspace withpnpm install --frozen-lockfile, copiesshared/server/websource, and runspnpm --filter @backspace/web buildto produce the static frontend (packages/web/dist).runtime(node:20-slim) — installs the native toolchain forbetter-sqlite3plusffmpeg(python3 make g++ ffmpeg), installs production-only deps withpnpm install --prod --frozen-lockfile(tsxis a server runtime dependency), copiesshared+serversource and the prebuiltweb/dist, creates/app/data/uploads, and starts the server withnode --import tsx/esm src/index.tsfrom/app/packages/server.
The server is run through tsx (no separate transpile step); TypeScript is executed directly at runtime.
AGPL § 13 commit injection. The runtime stage declares ARG BACKSPACE_COMMIT + ENV BACKSPACE_COMMIT=$BACKSPACE_COMMIT so the running build's git commit is baked into the image and read by config.commit (exposed via GET /api/instance/info). docker-compose.yml also declares it under build.args: { BACKSPACE_COMMIT: ${BACKSPACE_COMMIT:-} } (so a bare docker compose build picks it up from the environment). Both first-party build paths capture git rev-parse --short HEAD and feed it to the build:
install.sh(the public first-time path) reads the commit from the checkout the operator cloned and passes it explicitly asdocker compose build --build-arg BACKSPACE_COMMIT=<sha>.--build-argis used instead of an exported env var because it survives the sudo/non-sudo$COMPOSEsplit (an exported var would be stripped bysudo). A tarball install (no.git) yields an empty arg →null.deploy.shcaptures the commit locally (the remote has no.gitafter rsync) and exports it inline before the remotedocker compose up -d --build.
Empty/unset → config.commit is null (local dev, tarball install, or git unavailable). The source URL itself is config.sourceCodeUrl (env BACKSPACE_SOURCE_URL, default upstream) — operators running a modified build MUST set it to their fork.
Run: docker compose up -d --build
docker-compose.yml defines:
| Service | Image / Build | Role |
|---|---|---|
backspace |
build: . |
The application server. Binds ./data:/app/data (DB + uploads + backups), reads .env, sets DB_PATH=/app/data/backspace.db and UPLOAD_DIR=/app/data/uploads. restart: unless-stopped. |
caddy |
caddy:2.11.1-alpine |
Reverse proxy with automatic HTTPS. Owns ports 80/443. depends_on: backspace with condition: service_healthy. |
livekit |
livekit/livekit-server:v1.9.11 |
Voice/video SFU. network_mode: host. Activated only when COMPOSE_PROFILES=voice. |
Health-gated startup. The backspace service declares a healthcheck that polls /api/health (a route registered in packages/server/src/index.ts) every 30 s with a 30 s start_period. Caddy does not start proxying until the app reports healthy, so a deploy never routes traffic to a half-initialized server. The same healthcheck is duplicated in the Dockerfile HEALTHCHECK directive so the container reports health even when run outside Compose.
Caddy
Caddyfile reads {$DOMAIN} from the container environment (injected by Compose from .env) and:
- Strips a
/livekit/*prefix and reverse-proxies LiveKit signaling tohost.docker.internal:7880(LiveKit runs in host networking). - Reverse-proxies everything else — API, WebSocket, and the static frontend — to
backspace:3000over Docker's internal network.
Caddy provisions and renews TLS certificates automatically for DOMAIN; the persisted ACME state lives in the caddy-data / caddy-config named volumes.
First-time setup: install.sh
./install.sh is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the DOMAIN / ENABLE_VOICE / INSTANCE_NAME env vars for a non-interactive run), generates a JWT_SECRET, writes .env, optionally configures LiveKit (livekit.yaml + COMPOSE_PROFILES=voice), and brings the stack up.
Mode selection. Before configuring, it determines the deployment mode (precedence: explicit DEPLOY_MODE env → existing .env → auto-detect + prompt). Auto-detection checks whether ports 80/443 are free — and does so Docker-aware: it consults both ss and docker ps published ports, because a host running Docker with the userland proxy disabled DNATs 80/443 via iptables with no listening socket for ss to see (a box whose Caddy already owns those ports would otherwise be misread as "ports free"). When 80/443 are free it offers All-in-One (default); when taken it never dead-ends — it explains what holds them and steers to proxy/tunnel. In proxy/tunnel mode it auto-picks a free loopback APP_PORT (scanning past commonly-taken 3000/8080), force-lowers MAX_UPLOAD_SIZE to 90 MB for tunnel (Cloudflare's 100 MB body cap), and force-disables voice for tunnel.
Image acquisition. By default it pulls the prebuilt image (docker compose pull backspace). If the pull fails but a usable image is already present on the host (a prior run, an air-gapped docker load, or a previous from-source build tagged under the ref) it uses that copy rather than forcing a needless rebuild; only if neither pull nor a local image is available does it fall back to docker compose build (or when BACKSPACE_BUILD=true forces a source build, e.g. a fork). The commit is captured from the checkout and passed as --build-arg BACKSPACE_COMMIT=<sha> on the build path.
Reverse-proxy / tunnel output. In proxy/tunnel mode the post-deploy check verifies the app answers on 127.0.0.1:APP_PORT (TLS is the operator's edge's job, not ours to test), and the summary prints paste-ready nginx / Caddy / Traefik snippets (proxy) or a cloudflared ingress rule (tunnel), each with WebSocket upgrade, X-Forwarded-*, and a body-size cap matching MAX_UPLOAD_SIZE already correct — plus the /livekit route and media ports when voice is on.
Post-install HTTPS reachability check. The container healthcheck only proves the app is up inside Docker — not that https://DOMAIN actually works, which additionally requires Caddy to have obtained a publicly-trusted certificate (DNS pointing here and ports 80/443 reachable from the internet). After the stack is healthy, the installer verifies this and reports it honestly instead of always printing success:
- It polls
curl -fsS --resolve DOMAIN:443:127.0.0.1 https://DOMAIN/api/healthfor ~30 s. Using--resolveconnects to the local Caddy while presenting the real SNI/Host and performing full certificate verification, so a pass proves a valid public cert is installed and the app answers over TLS. This is deliberately hairpin-safe: many self-hosted boxes cannot reach their own public address (router NAT hairpin), so a plain external self-request would false-negative even when the site is fine for everyone else. - Live → the summary shows
HTTPS: Live. Not live yet →HTTPS: Not live yetplus guidance (point DNS here, open/forward ports 80/443, watchdocker compose logs -f caddy); Caddy keeps retrying and HTTPS comes up automatically once both are in place.
Redeploy: deploy.sh [pi|vm|all]
./deploy.sh is Heidi's redeploy helper for the two live instances — nova.ddns.net (Raspberry Pi) and orbit.ddns.net (VM). It does not build locally; it rsyncs the working tree to the target (excluding node_modules, .env, data/, build output, and a list of local-only paths) and then runs docker compose up -d --build on the remote so the image is rebuilt in place. Targets:
| Arg | Target |
|---|---|
pi / --local / --remote |
Raspberry Pi (auto-detects LAN vs. public DNS, or forced) |
vm / beta / orbit |
Beta VM |
all / both (default) |
Both, in parallel |
The data/ directory is excluded from the rsync, so application data on each box is never overwritten by a deploy.
2. Admin Bootstrap
There is no default/seed admin account. A fresh instance starts with zero users. Admin is granted by registration order, with a recovery net for the case where every admin is later deleted.
First registered user becomes admin
In packages/server/src/routes/auth.ts, registration computes:
const userCount = db.select().from(schema.users).all().length;
const isFirstUser = userCount === 0 && !homeInstance;
// ...
isAdmin: isFirstUser ? 1 : 0,
The very first locally-registered user (userCount === 0 and !homeInstance) is created with isAdmin = 1. The !homeInstance guard is a federation invariant: a replicated user (one whose identity is homed on another instance) is never an admin of this instance, even if they happen to be the first row written. This means the operator simply registers the first account after install to obtain admin rights — no credentials are printed, shipped, or stored anywhere.
Recovery net: ensureDefaults re-promotes the earliest user
ensureDefaults (packages/server/src/db/migrate.ts) runs on every boot, after migrations. Among its idempotent invariants:
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
if (!anyAdmin) {
const firstUser = db.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1').get();
if (firstUser) db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id);
}
If the instance ever ends up with no admins (e.g. the sole admin deleted their account), the next restart promotes the earliest-registered remaining user back to admin. This guarantees an instance can never become permanently un-administerable. It does not run when an admin already exists, so it never overrides the operator's chosen admin set during normal operation.
Seed-admin remediation (legacy instances only)
Instances installed before the no-seed-admin change still carry a local admin account whose password may be the old default admin123. That account cannot simply be deleted — the seeded admin owns the default space, so removing it would orphan the space. Instead, rotate its password with the remediation script:
docker exec -w /app/packages/server backspace \
node --import tsx/esm src/scripts/remediate-seed-admin.ts
Behavior (packages/server/src/scripts/remediate-seed-admin.ts):
- Targets only the local seed admin —
username = 'admin'withhome_instance IS NULLandis_admin = 1. Replicated/federated users are never touched. - Rotates only
admin123. It verifies the current hash againstadmin123; if the password has already been changed, it is a no-op ("nothing to do"). It is fully idempotent — safe to run repeatedly. - Never deletes the account (the default-space ownership constraint above).
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, and writes it to
data/seed-admin-rotated.txt(mode0600, root-owned via the bind-mount). Store the password somewhere safe, then deletedata/seed-admin-rotated.txt.
Note — sessions are not invalidated. Rotation changes the stored password hash only; it does not revoke existing JWTs. An already-logged-in admin session survives until the token expires (
JWT_EXPIRES_IN, default 30 days). Rotation closes off future logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotateJWT_SECRET(which invalidates all tokens instance-wide) and restart.
Remediation applies only to pre-change instances; newly installed instances never have a seed admin and need none of this.
3. Database Backups
Backspace takes DB-only SQLite snapshots via VACUUM INTO, which produces a consistent, fully-checkpointed copy of the live database without locking it for the duration of a file copy. Snapshots live in data/backups/ (configurable). Uploads and other files under data/ are not included — see the same-disk limitation below.
Triggers
| Trigger | Where | Reason tag | When |
|---|---|---|---|
| Pre-migration | packages/server/src/db/index.ts (initDatabase) |
pre-migration |
On startup, only when a migration is actually pending (see gating). |
| Scheduled | packages/server/src/utils/backupWorker.ts (startBackupWorker) |
scheduled |
Every BACKUP_INTERVAL_HOURS, via an unref'd setInterval. |
| Manual | ./backup.sh → src/scripts/snapshot.ts |
manual |
On demand by the operator. |
Snapshot filenames encode a millisecond-precision UTC timestamp and the reason tag — backspace-<ts>-<reason>.db — so they sort chronologically and never collide (createSnapshot disambiguates with a counter on the rare same-millisecond collision, since VACUUM INTO refuses to overwrite an existing file).
Pre-migration snapshot: gated and fail-closed
The pre-migration snapshot is deliberately conservative:
- Gated on a pending migration.
initDatabasesnapshots only when (a) the DB file already existed before this boot (captured before opening the handle, since opening creates the file — a post-open check would snapshot an empty 0-row DB on first boot) and (b)hasPendingMigrations(sqlite, migrationsFolder)returns true.hasPendingMigrations(db/pendingMigrations.ts) compares the applied-migration count in__drizzle_migrationsagainst the journal's entry count; a missing table (pre-drizzle / empty DB) counts as pending. Because schema history is stable across most restarts, this avoids churning the pre-migration retention with identical copies on every reboot. - Fail-closed: a snapshot failure aborts startup by design. If the snapshot throws (e.g. disk full),
initDatabaselogs and re-throws — the migration does not run and the server does not start. The box stays on the old code with its data intact until the operator frees space and restarts. Backspace never migrates the schema without first securing a backup. This is intentional: a failed-but-applied migration on an unbacked-up DB is the one unrecoverable scenario, so we refuse to enter it.
BACKUP_DISABLED=true turns off both the pre-migration snapshot and the scheduled worker (the gate at the top of initDatabase and the early return in startBackupWorker). Use it only when an external backup system owns data/.
WAL checkpoint on shutdown
On SIGINT/SIGTERM the server calls closeDatabase() (index.ts shutdown handler), which checkpoints the WAL so the on-disk backspace.db is a complete, self-contained file. This keeps host-side copies of data/backspace.db consistent even without going through VACUUM INTO (e.g. an off-box host backup of the whole data/ directory taken while the container is stopped).
Configuration
All vars are parsed in packages/server/src/config.ts under config.backup. Defaults shown.
| Var | Default | Meaning |
|---|---|---|
BACKUP_DIR |
<dir(DB_PATH)>/backups (i.e. data/backups) |
Where snapshots are written. |
BACKUP_INTERVAL_HOURS |
24 |
Scheduled-snapshot cadence. |
BACKUP_KEEP_SCHEDULED |
7 |
Scheduled snapshots retained (newest-first). |
BACKUP_KEEP_PREMIGRATION |
5 |
Pre-migration snapshots retained. |
BACKUP_KEEP_MANUAL |
10 |
Manual snapshots retained. |
BACKUP_OFFSITE_CMD |
(unset) | Off-box replication hook (see below). |
BACKUP_DISABLED |
false |
Disable all automatic snapshots. |
Retention / pruning
pruneSnapshots() enforces per-reason retention independently: it lists each reason's snapshots newest-first and unlinks everything past the keep count for that reason. Pruning runs after each scheduled and manual snapshot. (The pre-migration trigger does not prune inline — its own retention is enforced the next time a scheduled/manual snapshot prunes, and migrations are infrequent.) A failed prune is logged but never fatal.
Off-box replication hook
After writing a snapshot, createSnapshot invokes BACKUP_OFFSITE_CMD (if set). The command is run as sh -c '<cmd> "$1"' with the new snapshot's absolute path passed as $1, so your command is appended the snapshot path as a trailing argument (and may also reference "$1" explicitly for full control over the destination). This is best-effort: failures are logged, never fatal, and run asynchronously. Examples:
# Each resolves to: <cmd> "<absolute snapshot path>"
BACKUP_OFFSITE_CMD='rclone copy --quiet' # → rclone copy --quiet "<snapshot>" (dest must be in cmd, see below)
BACKUP_OFFSITE_CMD='aws s3 cp' # → aws s3 cp "<snapshot>" (append the bucket, see below)
# When you need to control the destination, reference "$1" yourself:
BACKUP_OFFSITE_CMD='rclone copyto -- "$1" remote:backspace/$(basename "$1")'
BACKUP_OFFSITE_CMD='aws s3 cp -- "$1" s3://my-bucket/backspace/'
BACKUP_OFFSITE_CMD='rsync -a -- "$1" backup-host:/srv/backspace-backups/'
Same-disk limitation (important)
Local snapshots live on the same disk as the live DB. They protect against:
- A bad migration (you can restore the pre-migration snapshot).
- Logical corruption or accidental data deletion.
They do not protect against hardware loss (disk failure, the box being destroyed). The snapshot dies with the disk that held the original.
To survive hardware loss you must replicate off the box. Either set
BACKUP_OFFSITE_CMDto push every snapshot to remote storage, or run a host-level backup of thedata/directory (which also captures uploads, not just the DB). One of these is required for real durability; the built-in snapshots alone are not a disaster-recovery solution.
4. Restore
Restores are driven by ./restore.sh from the host. Because data/ (including backspace.db and data/backups/) is container-owned (root) via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root alpine container that mounts data/.
List snapshots
./restore.sh
Lists every *.db in data/backups/ newest-first with its size, and prints the restore command. (No arguments = list-and-exit; it never modifies anything.)
Restore a snapshot
./restore.sh <snapshot-filename>
The argument is reduced to a basename — restore is always from data/backups/. After a y/N confirmation, the script performs:
[1/3]Stop thebackspacecontainer (docker compose stop backspace) so nothing is writing to the DB.[2/3]Swap inside a rootalpinecontainer (docker run --rm -v ./data:/data alpine sh -c …):- Pre-restore copy — if
data/backspace.dbexists, copy it todata/backups/backspace-<ts>-pre-restore.dbso the pre-restore state is recoverable. - Clear WAL/SHM —
rm -f data/backspace.db-wal data/backspace.db-shmso stale sidecar files don't corrupt the restored DB. - Install — copy the chosen snapshot over
data/backspace.db.
- Pre-restore copy — if
[3/3]Start the container (docker compose start backspace). On boot the server checkpoints/opens the restored DB and the healthcheck reports status (docker compose logs -f backspace).
The pre-restore copy means a mistaken restore is itself undoable: the previous DB is preserved as a *-pre-restore.db snapshot in data/backups/.
The
pre-restorereason tag is not in the auto-pruned reason set (pre-migration/scheduled/manual), so pre-restore copies are retained until manually cleaned up. Periodically prune old*-pre-restore.dbfiles by hand if disk is tight.
5. Image Pinning & Upgrades
The third-party images are pinned to explicit tags in docker-compose.yml, never latest:
| Service | Pinned image |
|---|---|
caddy |
caddy:2.11.1-alpine |
livekit |
livekit/livekit-server:v1.9.11 |
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you.
The backspace image itself defaults to ghcr.io/thezwiss/backspace:latest (BACKSPACE_IMAGE / BACKSPACE_IMAGE_TAG). latest is chosen for a frictionless first install, but it is a moving tag: operators who want reproducible upgrades should pin BACKSPACE_IMAGE_TAG to a released version (e.g. 1.0.0) in .env and bump it deliberately. On the source-build paths (deploy.sh, install.sh's fallback) the image is built from the Dockerfile, which pins the node:20-slim base.
Upgrade procedure: bump the tag in docker-compose.yml → test the new version (locally or on one box) → redeploy. Concretely:
- Edit the image tag in
docker-compose.yml(e.g.caddy:2.11.1-alpine→caddy:2.12.0-alpine). - Deploy to one box first (
./deploy.sh vm) and verify/api/healthis healthy and (for LiveKit) that voice still connects. - Once verified, roll it to the other box (
./deploy.sh pi, or./deploy.sh allgoing forward).
Never pin to a floating tag like latest or a bare major — it defeats reproducibility and turns every rebuild into an uncontrolled upgrade.
6. Known Limitations (out of scope)
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
deploy.shstill builds on each target host. The publicinstall.shpath now defaults to the prebuilt GHCR image (multi-arch, so a Pi pulls a native image), butdeploy.sh— Heidi's rsync-then-up -d --buildhelper fornova/orbit— deliberately builds from the rsynced working tree on the box (it caps the build cache and prunes old images to compensate). A native-module or toolchain regression can still surface on ARM but not x86, or vice-versa, on that path; the CI multi-arch build catches most such regressions before release.- A deploy causes brief downtime + WebSocket reconnect.
docker compose up -d --buildrebuilds and recreates thebackspacecontainer; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail. deploy.sh allcan mask one host failing. Thealltarget runs both deploys in parallel (deploy … & deploy … & wait). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After analldeploy, confirm/api/healthon both boxes rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
7. Quick Operator Reference
| Task | Command |
|---|---|
| First-time install | ./install.sh (or DOMAIN=chat.example.com DEPLOY_MODE=proxy ./install.sh) |
| Update (prebuilt image — default) | git pull && docker compose pull && docker compose up -d |
| Update (from source / fork) | git pull && docker compose up -d --build |
| Redeploy both boxes | ./deploy.sh all |
| Redeploy one box | ./deploy.sh pi / ./deploy.sh vm |
| Bring stack up manually | docker compose up -d (proxy/tunnel: .env's COMPOSE_FILE auto-adds the overlay) |
| Check health | curl -fsS https://<domain>/api/health |
| Take a manual snapshot | ./backup.sh |
| List snapshots | ./restore.sh |
| Restore a snapshot | ./restore.sh <snapshot-filename> |
| Rotate legacy seed admin | docker exec -w /app/packages/server backspace node --import tsx/esm src/scripts/remediate-seed-admin.ts |
| Grant first admin (fresh instance) | Register the first account; it becomes admin automatically. |