From 531b496618779a10c91085def2ca8d3837f7f17f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:36:29 +0200 Subject: [PATCH] feat(deploy): three deployment modes + prebuilt multi-arch image for robust self-hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 34 ++ .github/workflows/docker-publish.yml | 92 ++++ README.md | 221 +++++++- docker-compose.proxy.yml | 36 ++ docker-compose.yml | 8 + docs/systems/deployment.md | 60 ++- install.sh | 772 ++++++++++++++++++++++----- 7 files changed, 1070 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 docker-compose.proxy.yml diff --git a/.env.example b/.env.example index 61a3f6a9..f49418ca 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,29 @@ # Your server's public domain name (required) DOMAIN=example.com +# ─── Deployment mode ─────────────────────────────────────── +# How this instance is exposed to the internet. ./install.sh sets these for you; +# only touch them for a fully manual setup. +# +# allinone (default) — the bundled Caddy owns ports 80/443 and does automatic +# HTTPS for DOMAIN. Requires 80/443 free on this host. +# proxy — you run your OWN reverse proxy (nginx, Traefik, Caddy, +# Nginx Proxy Manager, SWAG …). No bundled Caddy; the app +# is published on 127.0.0.1:APP_PORT for your proxy to +# forward to. Use with: +# docker compose -f docker-compose.yml \ +# -f docker-compose.proxy.yml up -d +# tunnel — same as proxy, but fronted by a tunnel (Cloudflare +# Tunnel, Tailscale …). Point the tunnel's ingress at +# http://127.0.0.1:APP_PORT. NOTE: voice/WebRTC does NOT +# traverse a tunnel, and Cloudflare caps request bodies +# at 100 MB — set MAX_UPLOAD_SIZE below that (see below). +DEPLOY_MODE=allinone + +# Host loopback port the app is published on in `proxy`/`tunnel` mode (ignored in +# `allinone` mode). Your reverse proxy / tunnel forwards to 127.0.0.1:APP_PORT. +# APP_PORT=8080 + # ─── Server ───────────────────────────────────────────────── # Production/Docker listen port (Caddy reverse-proxies to it). Local development # ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`. @@ -25,6 +48,10 @@ JWT_SECRET= REGISTRATION_OPEN=true # Max file upload size in bytes (default: 100MB) +# TUNNEL USERS: Cloudflare (free/pro) hard-caps request bodies at 100 MB, so the +# 100 MB default lets uploads fail at the edge. Set this below the cap, e.g. +# 94371840 (90 MB), leaving headroom for multipart overhead. install.sh does +# this automatically when you pick `tunnel` mode. MAX_UPLOAD_SIZE=104857600 # ─── AGPL-3.0 § 13 Source Offer ──────────────────────────── @@ -39,6 +66,13 @@ MAX_UPLOAD_SIZE=104857600 # dev — the app reports commit=null. Set manually only for custom build pipelines. # BACKSPACE_COMMIT= +# ─── Prebuilt image (GHCR) ───────────────────────────────── +# By default the stack pulls the prebuilt multi-arch image +# ghcr.io/thezwiss/backspace:latest (published from tagged releases). Pin a +# specific version for reproducibility, or point at your own fork's registry. +# BACKSPACE_IMAGE=ghcr.io/thezwiss/backspace +# BACKSPACE_IMAGE_TAG=latest + # ─── LiveKit Voice/Video ─────────────────────────────────── # To enable voice/video, fill in all three values below and add: # COMPOSE_PROFILES=voice diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..e11adc35 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,92 @@ +name: Publish Container Image + +# Builds and publishes the Backspace application image to the GitHub Container +# Registry (GHCR) as a multi-architecture (linux/amd64 + linux/arm64) image, so +# self-hosters — including weak/ARM boxes like a Raspberry Pi — can `docker pull` +# a prebuilt image instead of building the ~1.6 GB image locally (the Vite build +# OOMs small ARM hosts). install.sh and docker-compose.yml default to pulling +# this image, with a from-source build as the fallback. +# +# This is intentionally SEPARATE from the desktop-installer workflow +# (release.yml) — they share the `v*` tag trigger but build entirely different +# artifacts and must not be entangled. + +on: + push: + tags: + - 'v*' + # Allow a manual rebuild/publish (e.g. to (re)publish `latest` or a moving tag + # without cutting a new release). + workflow_dispatch: + inputs: + tag: + description: 'Extra tag to publish (optional, e.g. "edge")' + required: false + default: '' + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # The runtime image bakes the git commit for the AGPL-3.0 § 13 source + # offer (config.commit → GET /api/instance/info). The .git dir is not in + # the build context (.dockerignore), so resolve the short SHA here and feed + # it to the build as a --build-arg, matching install.sh / deploy.sh. + - name: Resolve build metadata + id: meta_commit + run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image tags and labels + id: docker_meta + uses: docker/metadata-action@v5 + with: + # github.repository is "TheZwiss/backspace"; metadata-action lowercases + # it → ghcr.io/thezwiss/backspace (GHCR requires lowercase). + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha + type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event.inputs.tag != '' }} + labels: | + org.opencontainers.image.title=Backspace + org.opencontainers.image.description=Self-hosted Discord alternative — text, voice, video, and federation. + org.opencontainers.image.source=https://github.com/TheZwiss/backspace + org.opencontainers.image.licenses=AGPL-3.0-only + org.opencontainers.image.revision=${{ github.sha }} + + - name: Build and push (linux/amd64, linux/arm64) + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + build-args: | + BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }} + # Cache multi-arch layers across runs via the GitHub Actions cache to + # keep the ~1.6 GB build from re-running cold every release. + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 79b9754b..0fc028df 100644 --- a/README.md +++ b/README.md @@ -164,14 +164,26 @@ You own the server, the data, and the network it federates into. The intended way to deploy Backspace is the **interactive installer** — it configures everything (`.env`, secrets, HTTPS, optional voice) and brings the -stack up for you. Everything you need for a working instance is below. +stack up for you. It **auto-detects your environment** and picks one of three +deployment modes — the default "All-in-One" (below) needs nothing but a host and +a domain, but if ports 80/443 are already taken (an existing reverse proxy, a +tunnel, another app) the installer steers you to the right mode instead of +dead-ending. See [Deployment modes](#deployment-modes) for the full picture. + +By default the installer **pulls a prebuilt multi-architecture image** from the +GitHub Container Registry (`linux/amd64` + `linux/arm64`), so weak or ARM boxes +(a Raspberry Pi) skip the heavy local build — it falls back to building from +source automatically if the image can't be pulled. ### Requirements - A **Linux host** (VPS, VM, or home server) with **Docker** and **Docker Compose**. -- A **domain name** pointed at the host's public IP — Caddy uses it to obtain - HTTPS certificates automatically. -- The ability to open the firewall ports in step 2. +- A **domain name** for your instance. In the default All-in-One mode it must + point at the host's public IP (Caddy obtains HTTPS certificates for it + automatically); behind your own reverse proxy or a tunnel it points at that + edge instead — see [Deployment modes](#deployment-modes). +- The ability to open the firewall ports in step 2 (All-in-One), or a reverse + proxy / tunnel already terminating HTTPS for you. ### 1. Run the installer @@ -255,6 +267,202 @@ The stack runs three services via Docker Compose: | `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) | | `livekit` | Voice/video server — optional, enabled with `COMPOSE_PROFILES=voice` | +## Deployment modes + +Homelabs differ. Backspace supports three deployment modes from **one installer**, +which auto-detects which one fits and, in non-obvious cases, asks. The mode is +recorded as `DEPLOY_MODE` in `.env`; you can also set it up front for a +non-interactive install (`DEPLOY_MODE=proxy ./install.sh`). + +| Mode | When | HTTPS handled by | Voice | +|------|------|------------------|-------| +| **`allinone`** (default) | Ports 80/443 are free and you have a domain | The bundled **Caddy** (automatic Let's Encrypt) | ✅ with UDP media ports open | +| **`proxy`** | You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) | **Your** reverse proxy | ✅ if you also proxy `/livekit` and open the media ports | +| **`tunnel`** | You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…) | The **tunnel** provider | ❌ WebRTC/UDP can't traverse a tunnel | + +In `proxy` and `tunnel` mode the bundled Caddy is **not** started; instead the app +is published on **`127.0.0.1:APP_PORT`** (loopback only — never exposed directly) +for your proxy or tunnel to forward to. This is driven by a small overlay, +`docker-compose.proxy.yml`, which the installer wires in for you by setting +`COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` in `.env` — so every +later `docker compose …` command in the directory keeps working with no `-f` +flags. The installer auto-picks a free `APP_PORT` (3000/8080 are often taken); +override it with `APP_PORT=…`. + +The installer prints ready-to-paste config for your mode at the end. The +canonical snippets are below. + +### Mode 2 — behind your own reverse proxy + +The app answers plain HTTP on `127.0.0.1:APP_PORT`; your proxy terminates TLS and +forwards to it. Every snippet already includes the three things people get wrong: +**WebSocket upgrade** (chat and live events won't work without it), +**`X-Forwarded-*`** (the server runs with `trustProxy` and needs the real client +scheme/IP), and a **body-size limit** matching `MAX_UPLOAD_SIZE` (default 100 MB). + +Replace `chat.example.com` and `8080` with your domain and `APP_PORT`. + +**nginx** — the `map` goes in `http { }` once; the `server` block per site: + +```nginx +map $http_upgrade $connection_upgrade { default upgrade; '' close; } + +server { + listen 443 ssl; + server_name chat.example.com; + + # ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem; + + client_max_body_size 100m; # match MAX_UPLOAD_SIZE + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; # WebSocket + proxy_set_header Connection $connection_upgrade; # WebSocket + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Voice only — forward LiveKit signaling (strips the /livekit prefix): + # location /livekit/ { + # proxy_pass http://127.0.0.1:7880/; + # proxy_http_version 1.1; + # proxy_set_header Host $host; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection $connection_upgrade; + # } +} +``` + +**Caddy** (if you run your own — it handles WebSocket and `X-Forwarded-*` itself): + +```caddy +chat.example.com { + reverse_proxy 127.0.0.1:8080 + request_body { max_size 100MB } + + # Voice only: + # handle_path /livekit/* { reverse_proxy 127.0.0.1:7880 } + # handle { reverse_proxy 127.0.0.1:8080 } +} +``` + +**Traefik** (file provider — Traefik handles WebSocket automatically): + +```yaml +http: + routers: + backspace: + rule: "Host(`chat.example.com`)" + entryPoints: [websecure] + service: backspace + tls: { certResolver: letsencrypt } + services: + backspace: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" + # Voice only — add a higher-priority router + stripPrefix middleware for + # PathPrefix(`/livekit`) → http://127.0.0.1:7880. +``` + +#### GUI proxies (Nginx Proxy Manager, SWAG, etc.) + +You can't paste a config file into a point-and-click proxy, so set these fields +by hand. In **Nginx Proxy Manager**, add a **Proxy Host**: + +| Field | Value | +|-------|-------| +| **Domain Names** | `chat.example.com` | +| **Scheme** | `http` | +| **Forward Hostname / IP** | `127.0.0.1` — but **if NPM runs in Docker**, `127.0.0.1` is NPM's *own* container. Use the host's LAN IP, or `host.docker.internal` with `extra_hosts: ["host.docker.internal:host-gateway"]` on the NPM container. | +| **Forward Port** | your `APP_PORT` (e.g. `8080`) | +| **Websockets Support** | **ON** (required — chat/live events break without it) | +| **Block Common Exploits** | fine to leave on | +| **SSL tab** | request a Let's Encrypt cert and enable **Force SSL** | +| **Advanced tab** | add `client_max_body_size 100m;` (match `MAX_UPLOAD_SIZE`) | + +The same three ideas apply to any GUI proxy: forward to the app's host+port, +enable WebSocket support, and raise the request-body limit. + +### Mode 3 — behind a tunnel (Cloudflare, Tailscale…) + +Same loopback publish as Mode 2, but the tunnel daemon on the host reaches +`127.0.0.1:APP_PORT` and no inbound ports are opened at all. For **Cloudflare +Tunnel** (`cloudflared`): + +```yaml +# ~/.cloudflared/config.yml +tunnel: +credentials-file: /root/.cloudflared/.json + +ingress: + - hostname: chat.example.com + service: http://127.0.0.1:8080 + - service: http_status:404 +``` + +```bash +cloudflared tunnel route dns chat.example.com +``` + +Two tunnel-specific caveats, both handled by the installer: + +- **Upload cap.** Cloudflare (free/pro) hard-caps request bodies at **100 MB**, so + the 100 MB default would let large uploads fail *at the edge*. In `tunnel` mode + the installer sets `MAX_UPLOAD_SIZE=94371840` (90 MB) with headroom. Don't raise + it back above ~100 MB behind Cloudflare. +- **No voice.** Voice/video is **WebRTC over UDP**, which a tunnel can't carry — so + it's disabled in `tunnel` mode. If you need voice, use Mode 2 (reverse proxy) + with the media ports opened, or All-in-One. + +### Voice per mode + +Voice/video (LiveKit) needs its **UDP media ports** reachable from clients — +these carry the actual audio/video and never pass through your HTTP proxy or +tunnel: + +| Port | Proto | Purpose | +|------|-------|---------| +| `3478` | UDP | TURN — WebRTC NAT traversal | +| `7881` | TCP | WebRTC TCP fallback | +| `50000–60000` | UDP | WebRTC media (voice / video / screen-share) | + +- **All-in-One** — voice works once those ports are open/forwarded. LiveKit + *signaling* is proxied through Caddy on 443 (`/livekit`); port `7880` stays + internal, never forwarded. +- **Reverse proxy** — you must **also** route `/livekit` to `127.0.0.1:7880` (see + the commented lines in the snippets) **and** open the media ports above. +- **Tunnel** — voice does **not** work (UDP can't traverse the tunnel). This is a + known, unavoidable limitation, not a misconfiguration. + +### Updating a running instance + +Back up first — the app auto-snapshots the SQLite DB, and you can take one on +demand with `./backup.sh` (see [`docs/systems/deployment.md`](docs/systems/deployment.md)). +Then, from the install directory: + +```bash +git pull # refresh compose files / install.sh / docs + +# Prebuilt-image installs (the default): +docker compose pull && docker compose up -d + +# From-source installs (a fork, or BACKSPACE_BUILD=true): +docker compose up -d --build +``` + +Because `COMPOSE_FILE` lives in `.env`, these commands automatically use the +right compose files in every mode — no `-f` flags to remember. A redeploy +briefly restarts the `backspace` container (clients reconnect automatically). + ## Development Requirements: **Node.js 20 (LTS)** and **pnpm 10** — both are pinned (`.nvmrc` + @@ -300,10 +508,13 @@ The most important: |----------------------|----------|-------------|-------------| | `DOMAIN` | yes | — | Public domain name of your instance | | `JWT_SECRET` | yes | — | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) | +| `DEPLOY_MODE` | no | `allinone` | `allinone` \| `proxy` \| `tunnel` — see [Deployment modes](#deployment-modes) | +| `APP_PORT` | no | auto | `proxy`/`tunnel` only: host loopback port the app is published on | | `PORT` | no | `3000` | App listen port (behind Caddy in Docker) | | `HOST` | no | `0.0.0.0` | Bind address | | `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup | -| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB) | +| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB; 90 MB in `tunnel` mode) | +| `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG` | no | `ghcr.io/thezwiss/backspace` / `latest` | Prebuilt image to pull; pin a tag or point at your fork's registry | | `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | — | Enable voice/video | | `COMPOSE_PROFILES` | no | — | Set to `voice` to start the bundled LiveKit service | diff --git a/docker-compose.proxy.yml b/docker-compose.proxy.yml new file mode 100644 index 00000000..ef13ec3d --- /dev/null +++ b/docker-compose.proxy.yml @@ -0,0 +1,36 @@ +# ============================================================ +# Backspace — Reverse-proxy / Tunnel override +# ============================================================ +# Overlay for Mode 2 (behind your own reverse proxy) and Mode 3 (tunnel, e.g. +# Cloudflare Tunnel / Tailscale). Layer it on top of the base compose file: +# +# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d +# +# What it changes versus the all-in-one base: +# 1. Publishes the app on 127.0.0.1:${APP_PORT} (loopback only) so your own +# reverse proxy or tunnel daemon on the same host can reach it. Nothing is +# exposed on a public interface by this stack — TLS/termination is the +# proxy's job. +# 2. Moves the bundled Caddy into a profile that is never activated here, so it +# does NOT start (your proxy owns 80/443). The base file leaves Caddy in the +# default profile, so Mode 1 (`-f docker-compose.yml` alone) is unchanged. +# +# ./install.sh selects the right `-f` combination automatically per mode; this +# file is also usable by hand for a fully manual setup. +# ============================================================ + +services: + backspace: + # Bind to loopback only. The reverse proxy / tunnel connects over 127.0.0.1; + # the app is never reachable directly from the network. Container listens on + # PORT (default 3000); APP_PORT is the host-side port your proxy forwards to. + ports: + - "127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}" + + caddy: + # Park Caddy in a profile that install.sh / the documented commands never + # enable, so the merged config drops it in proxy/tunnel mode. (Compose + # replaces the `profiles` list on merge; the base service has none, so this + # is the effective value only when this override is layered on.) + profiles: + - _proxy_mode_no_caddy diff --git a/docker-compose.yml b/docker-compose.yml index 907502c1..10a3ed09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,14 @@ services: # ── Backspace application server ────────────────────────── backspace: + # Prebuilt multi-arch image on GHCR (published by .github/workflows/ + # docker-publish.yml). `docker compose pull` / install.sh's default path + # fetches this so weak/ARM hosts skip the heavy local build. Both `image:` + # and `build:` are declared: if the image isn't present locally and can't be + # pulled, `docker compose up --build` (install.sh's fallback, and deploy.sh) + # builds from source instead and tags the result under this same ref. + # Override the tag with BACKSPACE_IMAGE_TAG in .env (defaults to `latest`). + image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest} build: context: . args: diff --git a/docs/systems/deployment.md b/docs/systems/deployment.md index a93807d5..220a7762 100644 --- a/docs/systems/deployment.md +++ b/docs/systems/deployment.md @@ -4,9 +4,11 @@ Operator- and contributor-facing reference for hosting Backspace: the Docker bui Source files: - `Dockerfile` -- multi-stage build (builder → runtime) -- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck -- `Caddyfile` -- reverse proxy / auto-HTTPS config -- `install.sh` -- interactive first-time setup +- `docker-compose.yml` -- base stack: `backspace` + `caddy` (+ optional `livekit`) services, healthcheck +- `docker-compose.proxy.yml` -- proxy/tunnel overlay: publishes the app on `127.0.0.1:APP_PORT` and drops Caddy +- `.github/workflows/docker-publish.yml` -- multi-arch (amd64+arm64) GHCR image publish +- `Caddyfile` -- 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 boxes - `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side) - `packages/server/src/config.ts` -- `config.backup.*` env parsing @@ -25,7 +27,35 @@ Source files: ## 1. Pipeline Overview -Backspace ships as a single application container fronted by Caddy. Everything is built and run via Docker Compose; there is no separate CI artifact — **the image is built on each target host** from source. +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](#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` + `buildx` build `linux/amd64,linux/arm64` in one push, so a Raspberry Pi pulls a native image instead of cross-building (the Vite build OOMs small ARM boxes). +- **Tags.** `docker/metadata-action` derives `{version}`, `{major}.{minor}`, `latest` (on `v*` tags), and `sha-`. A `workflow_dispatch` with an extra `tag` input publishes that tag too (e.g. `latest` without cutting a release). +- **AGPL § 13 commit stamping is preserved.** The workflow resolves `git rev-parse --short HEAD` and passes it as `--build-arg BACKSPACE_COMMIT=…`, exactly like `install.sh`/`deploy.sh`, plus OCI labels (`source`, `licenses=AGPL-3.0-only`, `revision`). The pulled image therefore advertises its exact source version via `GET /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 unauthenticated `docker pull` to work. +- **Compose wiring.** `docker-compose.yml` declares **both** `image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}` **and** `build: .`. `pull`/`up` uses 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 via `BACKSPACE_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 @@ -65,7 +95,13 @@ Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the per ### 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 (`docker compose build` then `up -d`). +`./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=` 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: @@ -247,14 +283,16 @@ The pre-restore copy means a mistaken restore is itself undoable: the previous D ## 5. Image Pinning & Upgrades -The two pulled images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`: +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 is built from source via the `Dockerfile`, which itself pins the `node:20-slim` base.) +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: @@ -270,7 +308,7 @@ Never pin to a floating tag like `latest` or a bare major — it defeats reprodu These are accepted constraints of the current deploy model, documented so operators aren't surprised: -- **The image is built on each target host, including the ARM Raspberry Pi.** There is no cross-built/registry-pushed artifact. The Pi build is slower and consumes build resources on the box (`deploy.sh` caps the build cache and prunes old images to compensate). A native-module or toolchain regression can surface on ARM but not x86, or vice-versa. +- **`deploy.sh` still builds on each target host.** The public `install.sh` path now defaults to the prebuilt GHCR image (multi-arch, so a Pi pulls a native image), but `deploy.sh` — Heidi's rsync-then-`up -d --build` helper for `nova`/`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 --build` rebuilds and recreates the `backspace` container; 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 all` can mask one host failing.** The `all` target 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 an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time. @@ -280,10 +318,12 @@ These are accepted constraints of the current deploy model, documented so operat | Task | Command | |------|---------| -| First-time install | `./install.sh` (or `DOMAIN=chat.example.com ./install.sh`) | +| 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 --build` | +| Bring stack up manually | `docker compose up -d` (proxy/tunnel: `.env`'s `COMPOSE_FILE` auto-adds the overlay) | | Check health | `curl -fsS https:///api/health` | | Take a manual snapshot | `./backup.sh` | | List snapshots | `./restore.sh` | diff --git a/install.sh b/install.sh index 0abb88fb..12a943fd 100755 --- a/install.sh +++ b/install.sh @@ -2,13 +2,31 @@ # ============================================================ # Backspace — Production Installer # ============================================================ -# Sets up Backspace with Caddy (auto-HTTPS) and optional -# LiveKit (voice/video) on a Linux server. +# Sets up Backspace on a Linux server in one of three deployment modes, +# auto-detecting which one fits your environment: +# +# allinone The bundled Caddy owns ports 80/443 and does automatic HTTPS +# for your domain. The simplest setup — pick this if 80/443 are free. +# proxy You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy +# Manager, SWAG…). Backspace is published on 127.0.0.1:APP_PORT and +# the installer prints ready-to-paste proxy config. No bundled Caddy. +# tunnel You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…). +# Same as proxy, plus tunnel-specific guidance. (Voice does not work +# over a tunnel — WebRTC/UDP can't traverse it.) # # Usage: -# ./install.sh Interactive setup +# ./install.sh Interactive setup (auto-detects the mode) +# # Non-interactive — set any/all of these to skip the matching prompt: -# DOMAIN=chat.example.com ENABLE_VOICE=true INSTANCE_NAME="My Chat" ./install.sh +# DOMAIN=chat.example.com \ +# DEPLOY_MODE=allinone|proxy|tunnel \ +# APP_PORT=8080 \ # proxy/tunnel only; auto-picked if unset +# ENABLE_VOICE=true \ +# INSTANCE_NAME="My Chat" \ +# ./install.sh +# +# BACKSPACE_BUILD=true ./install.sh Force a local from-source build instead of +# pulling the prebuilt image (fork operators). # ============================================================ set -euo pipefail @@ -97,56 +115,84 @@ if ! $COMPOSE version &>/dev/null 2>&1; then fi success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')" -# Check if ports 80/443 are available -check_port() { - local port=$1 - if ss -tlnp 2>/dev/null | grep -qE ":${port}\b"; then - local proc - proc=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | grep -oP 'users:\(\("\K[^"]+' | head -1 || echo "unknown") - error "Port $port is already in use by: $proc" - error "Free port $port before running this script." - return 1 - fi - return 0 +# ── Port helpers ──────────────────────────────────────────── +# A host port can be held in two ways this installer must detect: +# 1. A normal listening socket (host process) → visible via `ss -tln`. +# 2. A Docker-published port. With the userland proxy *disabled* +# (`userland-proxy: false`, common on tuned hosts), Docker DNATs the port +# with iptables and there is NO listening socket for `ss` to see. So we must +# also consult `docker ps` — otherwise a box whose Caddy already owns 80/443 +# via iptables would be misreported as "ports free". + +# Does any running container publish this host port? (matches "…:->" in the +# Ports column; the ":" before the port and "->" after pin it to the HOST port, +# so :80 doesn't match :8080 and never matches the container-side port.) +docker_publishes_port() { + $DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -qE ":${1}->" } -# Only check ports if we're NOT already running (upgrade scenario) -if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then - port_ok=true - check_port 80 || port_ok=false - check_port 443 || port_ok=false - if [[ "$port_ok" == false ]]; then - exit 1 +# Same, but ignore Backspace's own container (used when re-running on a host that +# already runs this instance — its published port must not count as a conflict). +docker_publishes_port_other() { + $DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \ + | grep -vE '^backspace ' | grep -qE ":${1}->" +} + +# Is the port in use by anything (host socket OR any container)? +port_in_use() { + local port=$1 + if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then + return 0 fi - success "Ports 80 and 443 are available" -else - success "Caddy is already running (upgrade mode)" + docker_publishes_port "$port" +} + +# In use by something OTHER than our own Backspace container? +port_in_use_by_other() { + local port=$1 + if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then + return 0 + fi + docker_publishes_port_other "$port" +} + +# Best-effort human description of what holds a port (for helpful warnings). +port_holder() { + local port=$1 holder="" + holder=$($DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \ + | grep -E ":${port}->" | awk '{print $1}' | head -1 || true) + if [[ -n "$holder" ]]; then + echo "docker container '$holder'" + return + fi + holder=$(ss -tlnp 2>/dev/null | grep -E ":${port}[[:space:]]" \ + | grep -oP 'users:\(\("\K[^"]+' | head -1 || true) + echo "${holder:-another process}" +} + +# ── openssl (needed for secret generation) ────────────────── +if ! command -v openssl &>/dev/null; then + error "openssl is required for generating secrets." + error "Install: sudo apt-get install openssl" + exit 1 fi # Disk space check available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true) if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)" + warn "The prebuilt image (~1.6GB pulled) needs less than a from-source build." else success "Disk space OK" fi -# Check for openssl (needed for secret generation) -if ! command -v openssl &>/dev/null; then - error "openssl is required for generating secrets." - error "Install: sudo apt-get install openssl" - exit 1 -fi - # ── Phase 2: Configuration ───────────────────────────────── step "Configuration" # Detect existing installation -EXISTING_ENV=false if [[ -f .env ]]; then - EXISTING_ENV=true - info "Existing .env detected — secrets will be preserved." + info "Existing .env detected — secrets and settings will be preserved." fi # Helper: read existing .env value (|| true prevents set -e from killing @@ -157,17 +203,131 @@ env_val() { fi } +# ── Deployment mode ───────────────────────────────────────── +# Precedence: explicit DEPLOY_MODE env → existing .env → auto-detect + prompt. +# Auto-detect never dead-ends: if 80/443 are taken, All-in-One is simply off the +# menu and we steer the operator to proxy/tunnel instead. + +existing_mode=$(env_val DEPLOY_MODE) +CADDY_RUNNING=false +if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then + CADDY_RUNNING=true +fi + +port80_free=true; port443_free=true +port_in_use 80 && port80_free=false +port_in_use 443 && port443_free=false + +choose_mode_interactively() { + # Writes the chosen mode to the global DEPLOY_MODE. + local choice="" + if [[ "$port80_free" == true && "$port443_free" == true ]]; then + echo " Ports 80 and 443 are free — All-in-One (bundled Caddy + auto-HTTPS) is available." + echo "" + echo " How do you want to expose Backspace?" + echo -e " ${BOLD}1)${NC} All-in-One — bundled Caddy handles HTTPS for you ${GREEN}(recommended)${NC}" + echo -e " ${BOLD}2)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…)" + echo -e " ${BOLD}3)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)" + echo "" + read -rp " Choice [1]: " choice || choice="" + case "${choice:-1}" in + 1) DEPLOY_MODE=allinone ;; + 2) DEPLOY_MODE=proxy ;; + 3) DEPLOY_MODE=tunnel ;; + *) DEPLOY_MODE=allinone ;; + esac + else + warn "Ports 80/443 are already in use on this host:" + [[ "$port80_free" == false ]] && echo " 80 → held by $(port_holder 80)" + [[ "$port443_free" == false ]] && echo " 443 → held by $(port_holder 443)" + echo "" + info "All-in-One needs 80 and 443 free, so it's unavailable here." + info "That's fine — run Backspace behind what already owns those ports:" + echo "" + echo -e " ${BOLD}1)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) ${GREEN}(recommended)${NC}" + echo -e " ${BOLD}2)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)" + echo -e " ${BOLD}3)${NC} Nothing yet — I'll free 80/443 and use All-in-One (exit so I can do that)" + echo "" + read -rp " Choice [1]: " choice || choice="" + case "${choice:-1}" in + 1) DEPLOY_MODE=proxy ;; + 2) DEPLOY_MODE=tunnel ;; + 3) + error "Free ports 80 and 443, then re-run ./install.sh for All-in-One mode." + exit 1 + ;; + *) DEPLOY_MODE=proxy ;; + esac + fi +} + +if [[ -n "${DEPLOY_MODE:-}" ]]; then + info "Deployment mode: ${DEPLOY_MODE} (from environment)" +elif [[ -n "$existing_mode" ]]; then + DEPLOY_MODE="$existing_mode" + info "Deployment mode: ${DEPLOY_MODE} (from existing .env)" + info "To switch modes, re-run with DEPLOY_MODE=allinone|proxy|tunnel." +elif [[ -t 0 ]]; then + echo "" + choose_mode_interactively +else + # Non-interactive with no DEPLOY_MODE: pick a safe, never-dead-end default. + if [[ "$port80_free" == true && "$port443_free" == true ]]; then + DEPLOY_MODE=allinone + else + DEPLOY_MODE=proxy + warn "Ports 80/443 are in use and no DEPLOY_MODE was given — defaulting to 'proxy'." + fi + info "Deployment mode: ${DEPLOY_MODE} (auto-detected)" +fi + +case "$DEPLOY_MODE" in + allinone|proxy|tunnel) ;; + *) + error "Invalid DEPLOY_MODE='${DEPLOY_MODE}'. Use allinone, proxy, or tunnel." + exit 1 + ;; +esac + +# All-in-One requires 80/443 — but a re-run over an existing All-in-One instance +# legitimately finds *its own* Caddy holding them (an upgrade), which is fine. +if [[ "$DEPLOY_MODE" == "allinone" ]]; then + if [[ "$CADDY_RUNNING" == true ]]; then + success "Caddy is already running (All-in-One upgrade)" + elif [[ "$port80_free" == false || "$port443_free" == false ]]; then + error "All-in-One mode needs ports 80 and 443 free, but:" + [[ "$port80_free" == false ]] && error " 80 is held by $(port_holder 80)" + [[ "$port443_free" == false ]] && error " 443 is held by $(port_holder 443)" + error "Free them, or re-run with DEPLOY_MODE=proxy (or =tunnel) to run behind them." + exit 1 + else + success "Ports 80 and 443 are available" + fi +fi + +# In proxy/tunnel mode, layer the proxy override so Caddy is dropped and the app +# is published on a loopback port. Compose reads COMPOSE_FILE (from the shell and +# from .env) to pick up both files for every command — the operator never needs +# to remember `-f` flags afterwards. +if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then + export COMPOSE_FILE="docker-compose.yml:docker-compose.proxy.yml" +else + unset COMPOSE_FILE 2>/dev/null || true +fi + # ── Domain ────────────────────────────────────────────────── +# Required in every mode: it's the public hostname clients use and it drives the +# federation identity + LiveKit URL. In proxy/tunnel mode TLS is terminated at +# your edge, but the app still advertises https://DOMAIN. existing_domain=$(env_val DOMAIN) if [[ -n "${DOMAIN:-}" ]]; then - # Non-interactive: DOMAIN set via environment : elif [[ -n "$existing_domain" ]]; then - read -rp "Domain [$existing_domain]: " DOMAIN + read -rp "Domain [$existing_domain]: " DOMAIN || DOMAIN="" DOMAIN="${DOMAIN:-$existing_domain}" else - read -rp "Domain (e.g., chat.example.com): " DOMAIN + read -rp "Domain (e.g., chat.example.com): " DOMAIN || DOMAIN="" fi if [[ -z "${DOMAIN:-}" ]]; then @@ -175,42 +335,93 @@ if [[ -z "${DOMAIN:-}" ]]; then exit 1 fi -# DNS verification -info "Verifying DNS for ${DOMAIN}..." +# DNS verification is meaningful for All-in-One (Caddy must reach this host to +# issue a certificate). In proxy/tunnel mode the DNS record points at your proxy +# or tunnel edge — often NOT this host's IP (that's the whole point) — so we only +# note what it resolves to, without warning about a mismatch. +info "Checking DNS for ${DOMAIN}..." resolved_ip="" if command -v dig &>/dev/null; then resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true) elif command -v getent &>/dev/null; then - # A non-resolving domain makes `getent hosts` exit 2, and with `set -o - # pipefail` that would abort the installer here — before the graceful - # "Could not resolve" warning below. Swallow it: an empty result is the - # intended "not resolved yet" signal (installing before DNS is set up is - # explicitly supported). resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1 || true) fi -my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "") - -if [[ -z "$resolved_ip" ]]; then - warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates." -elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then - warn "${DOMAIN} resolves to ${resolved_ip}, but this server appears to be ${my_ip}" - warn "Let's Encrypt certificate issuance may fail if DNS doesn't point here." +if [[ "$DEPLOY_MODE" == "allinone" ]]; then + my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "") + if [[ -z "$resolved_ip" ]]; then + warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates." + elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then + warn "${DOMAIN} resolves to ${resolved_ip}, but this server appears to be ${my_ip}" + warn "Let's Encrypt certificate issuance may fail if DNS doesn't point here." + else + success "${DOMAIN} resolves to ${resolved_ip:-verified}" + fi else - success "${DOMAIN} resolves to ${resolved_ip:-verified}" + if [[ -n "$resolved_ip" ]]; then + info "${DOMAIN} currently resolves to ${resolved_ip} (should point at your proxy/tunnel edge)." + else + info "${DOMAIN} does not resolve yet — point it at your proxy/tunnel edge when ready." + fi +fi + +# ── App port (proxy / tunnel only) ────────────────────────── +# The loopback host port your proxy/tunnel forwards to. Auto-picked to avoid +# common clashes (3000/8080 are frequently taken) unless APP_PORT is set. + +pick_free_port() { + if [[ -n "${APP_PORT:-}" ]]; then + if port_in_use_by_other "$APP_PORT"; then + error "Requested APP_PORT=$APP_PORT is already in use by $(port_holder "$APP_PORT")." + exit 1 + fi + echo "$APP_PORT"; return + fi + local existing; existing=$(env_val APP_PORT) + if [[ -n "$existing" ]]; then + # Re-run: keep the existing port so the proxy/tunnel config stays valid, even + # though our own container is currently publishing it. + echo "$existing"; return + fi + local p + for p in 8080 8081 8082 8090 8095 3001 3002 18080 28080; do + if ! port_in_use "$p"; then echo "$p"; return; fi + done + for p in $(seq 8100 8200); do + if ! port_in_use "$p"; then echo "$p"; return; fi + done + error "Could not find a free host port for the app. Set APP_PORT to a free port and re-run." + exit 1 +} + +APP_PORT_FINAL="" +if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then + APP_PORT_FINAL="$(pick_free_port)" + success "App will be published on 127.0.0.1:${APP_PORT_FINAL}" fi # ── Voice/Video ───────────────────────────────────────────── +# Voice needs open UDP media ports and (in proxy mode) a /livekit route. Over a +# tunnel, WebRTC/UDP cannot traverse the edge at all — so voice is force-disabled +# in tunnel mode rather than silently configured and then failing at call time. existing_profiles=$(env_val COMPOSE_PROFILES) -if [[ -n "${ENABLE_VOICE:-}" ]]; then - # Non-interactive +if [[ "$DEPLOY_MODE" == "tunnel" ]]; then + if [[ "${ENABLE_VOICE:-}" == "true" ]]; then + warn "Voice cannot work over a tunnel (WebRTC/UDP can't traverse it) — disabling it." + fi + ENABLE_VOICE=false + info "Voice/video is disabled in tunnel mode (a known, unavoidable limitation)." +elif [[ -n "${ENABLE_VOICE:-}" ]]; then : elif [[ "$existing_profiles" == *"voice"* ]]; then - read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn + read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn || yn="" ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true) else - read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn + if [[ "$DEPLOY_MODE" == "proxy" ]]; then + info "Voice needs open UDP media ports AND a /livekit route in your proxy (snippet printed at the end)." + fi + read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn || yn="" ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true) fi ENABLE_VOICE="${ENABLE_VOICE:-true}" @@ -219,10 +430,26 @@ ENABLE_VOICE="${ENABLE_VOICE:-true}" existing_name=$(env_val INSTANCE_NAME) if [[ -z "${INSTANCE_NAME:-}" ]]; then - read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME + read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME || INSTANCE_NAME="" INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}" fi +# ── Max upload size ───────────────────────────────────────── +# Cloudflare (free/pro) hard-caps request bodies at 100MB, so a 100MB app limit +# lets uploads fail at the edge instead of in-app. In tunnel mode we default the +# cap below that (90MB) with headroom for multipart overhead. An explicit +# MAX_UPLOAD_SIZE (env or existing .env) always wins. +existing_max=$(env_val MAX_UPLOAD_SIZE) +if [[ -n "${MAX_UPLOAD_SIZE:-}" ]]; then + : +elif [[ -n "$existing_max" ]]; then + MAX_UPLOAD_SIZE="$existing_max" +elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then + MAX_UPLOAD_SIZE=94371840 # 90 MB — under Cloudflare's 100MB body cap +else + MAX_UPLOAD_SIZE=104857600 # 100 MB +fi + # ── Phase 3: Generate Secrets ─────────────────────────────── step "Generating configuration" @@ -259,12 +486,31 @@ step "Writing configuration files" # ── .env ──────────────────────────────────────────────────── -cat > .env << EOF +{ + cat << EOF # Backspace Configuration # Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") DOMAIN=${DOMAIN} +# Deployment mode: allinone | proxy | tunnel (see ./install.sh --help / README) +DEPLOY_MODE=${DEPLOY_MODE} +EOF + + if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then + cat << EOF + +# Reverse-proxy / tunnel mode: layer the proxy override so the bundled Caddy is +# dropped and the app is published on 127.0.0.1:APP_PORT for your edge to reach. +# COMPOSE_FILE makes every 'docker compose' command in this directory use both +# files automatically — no -f flags needed. +COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml +APP_PORT=${APP_PORT_FINAL} +EOF + fi + + cat << EOF + # Server PORT=3000 HOST=0.0.0.0 @@ -275,12 +521,12 @@ JWT_SECRET=${JWT_SECRET} # Registration REGISTRATION_OPEN=true -# Max upload size in bytes (100MB) -MAX_UPLOAD_SIZE=104857600 +# Max upload size in bytes +MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE} EOF -if [[ "$ENABLE_VOICE" == true ]]; then - cat >> .env << EOF + if [[ "$ENABLE_VOICE" == true ]]; then + cat << EOF # LiveKit Voice/Video LIVEKIT_URL=wss://${DOMAIN}/livekit @@ -290,8 +536,8 @@ LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} # Activate the LiveKit service in Docker Compose COMPOSE_PROFILES=voice EOF -else - cat >> .env << EOF + else + cat << EOF # LiveKit Voice/Video (disabled) # To enable: fill in credentials and add COMPOSE_PROFILES=voice @@ -299,7 +545,8 @@ else # LIVEKIT_API_KEY= # LIVEKIT_API_SECRET= EOF -fi + fi +} > .env success ".env" @@ -382,14 +629,43 @@ step "Deploying Backspace" # so GET /api/instance/info advertises the exact source version. Passed straight # to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike # an exported env var). Empty when this isn't a git checkout (e.g. tarball -# install) or git is unavailable → the server treats the commit as null. +# install) or git is unavailable → the server treats the commit as null. A pulled +# prebuilt image already carries the commit baked at CI build time. BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')" -if [[ -n "$BUILD_COMMIT" ]]; then - info "Building Backspace image from commit ${BUILD_COMMIT} (this may take a few minutes on first run)..." + +build_from_source() { + if [[ -n "$BUILD_COMMIT" ]]; then + info "Building Backspace image from commit ${BUILD_COMMIT} (first build takes a few minutes)..." + else + info "Building Backspace image (first build takes a few minutes)..." + fi + $COMPOSE build --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT" +} + +# Default path: pull the prebuilt multi-arch image (fast, and it spares weak/ARM +# hosts the ~1.6GB local build that OOMs small boxes). Fall back to a from-source +# build if the image can't be pulled (not published yet, private, or offline), or +# if the operator forces a build (BACKSPACE_BUILD=true — e.g. running a fork). +if [[ "${BACKSPACE_BUILD:-false}" == "true" ]]; then + info "BACKSPACE_BUILD=true — building from source (skipping the prebuilt image)." + build_from_source else - info "Building Backspace image (this may take a few minutes on first run)..." + image_ref="${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}" + info "Fetching prebuilt image ${image_ref} ..." + if $COMPOSE pull backspace; then + success "Pulled prebuilt image" + elif $DOCKER image inspect "$image_ref" >/dev/null 2>&1; then + # Pull failed (offline / registry hiccup / private) but a usable copy is + # already on this host (a prior run, an air-gapped `docker load`, or a + # previous from-source build tagged under this ref) — use it instead of + # forcing a needless multi-hundred-MB rebuild. + warn "Could not pull ${image_ref} — using the copy already present on this host." + else + warn "Prebuilt image unavailable (not published yet, private, or offline)." + warn "Falling back to a from-source build — slower, and heavy on low-RAM/ARM hosts." + build_from_source + fi fi -$COMPOSE build --quiet --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT" info "Starting services..." $COMPOSE up -d @@ -427,92 +703,312 @@ if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspac ' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)" fi -# ── Phase 7.5: Verify HTTPS reachability ─────────────────── -# The internal healthcheck only proves the app is up *inside* Docker. What the -# operator actually cares about is whether https://DOMAIN works — which needs -# Caddy to have obtained a publicly-trusted TLS certificate, and that only -# happens once DNS points here AND ports 80/443 are reachable from the internet. -# -# We test this hairpin-safely with `curl --resolve DOMAIN:443:127.0.0.1`: it -# connects to the LOCAL Caddy but presents the real SNI/Host and performs full -# certificate verification. Success means Caddy is serving a valid, publicly- -# trusted certificate for DOMAIN *and* the app answers over it — which is only -# possible once issuance has succeeded. Crucially this avoids a false negative -# on self-hosted boxes that can't reach their own public address (router NAT -# hairpin), where a plain external request would time out even though the site -# is perfectly reachable for everyone else. +# ── Phase 7.5: Post-deploy reachability check ────────────── +# What's verifiable differs by mode: +# allinone → prove https://DOMAIN works end-to-end (Caddy has a valid, +# publicly-trusted certificate AND the app answers over it). +# proxy/tunnel → prove the app answers on its loopback port (your edge then +# fronts it); TLS is your proxy/tunnel's job, not ours to test. https_status="skipped" -if [[ "$healthy" == true ]]; then - step "Verifying HTTPS" - https_status="pending" +app_reachable="unknown" - info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..." - for i in $(seq 1 15); do - if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then - https_status="live" - break +if [[ "$DEPLOY_MODE" == "allinone" ]]; then + if [[ "$healthy" == true ]]; then + step "Verifying HTTPS" + https_status="pending" + # `curl --resolve DOMAIN:443:127.0.0.1` connects to the LOCAL Caddy but + # presents the real SNI/Host and does full certificate verification. A pass + # proves a valid public cert is installed AND the app answers over it — and + # it's hairpin-safe (many self-hosted boxes can't reach their own public IP). + info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..." + for i in $(seq 1 15); do + if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then + https_status="live" + break + fi + sleep 2 + done + if [[ "$https_status" == "live" ]]; then + success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it." + else + warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate." + fi + fi +else + if [[ "$healthy" == true ]]; then + step "Verifying the app" + app_reachable="no" + for i in $(seq 1 10); do + if curl -fsS --max-time 6 "http://127.0.0.1:${APP_PORT_FINAL}/api/health" >/dev/null 2>&1; then + app_reachable="yes" + break + fi + sleep 1 + done + if [[ "$app_reachable" == "yes" ]]; then + success "Backspace is answering on http://127.0.0.1:${APP_PORT_FINAL} — point your $( [[ "$DEPLOY_MODE" == tunnel ]] && echo tunnel || echo 'reverse proxy') at it." + else + warn "Could not reach http://127.0.0.1:${APP_PORT_FINAL}/api/health yet — check: docker compose logs backspace" fi - sleep 2 - done - - if [[ "$https_status" == "live" ]]; then - success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it." - else - warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate." fi fi +# ── Reverse-proxy / tunnel snippet generators ────────────── +# Paste-ready configs with WebSocket upgrade, X-Forwarded-*, and a body-size cap +# already correct. Printed for proxy/tunnel modes so the operator's edge routes +# to 127.0.0.1:APP_PORT (and, if voice is on, /livekit → the host LiveKit). + +hr() { echo -e "${CYAN}────────────────────────────────────────────────────────────${NC}"; } +snip() { echo -e "${BOLD}$*${NC}"; } + +# Body-size cap in the proxy should match the app's MAX_UPLOAD_SIZE. Express it in +# MB for the human-facing proxy directives (round up so the proxy never rejects a +# body the app would accept). +max_mb=$(( (MAX_UPLOAD_SIZE + 1048575) / 1048576 )) + +print_nginx_snippet() { + snip "nginx — add the map once inside http { }, then a server block:" + hr + cat << EOF +# --- inside http { } (once) -------------------------------- +map \$http_upgrade \$connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl; + server_name ${DOMAIN}; + + # Your TLS certs (certbot, your proxy manager, etc.): + # ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; + + client_max_body_size ${max_mb}m; # match MAX_UPLOAD_SIZE (${MAX_UPLOAD_SIZE} bytes) +EOF + if [[ "$ENABLE_VOICE" == true ]]; then + cat << EOF + + # Voice signaling → host-networked LiveKit (strips the /livekit prefix): + location /livekit/ { + proxy_pass http://127.0.0.1:7880/; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection \$connection_upgrade; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } +EOF + fi + cat << EOF + + location / { + proxy_pass http://127.0.0.1:${APP_PORT_FINAL}; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_set_header X-Forwarded-Host \$host; + # WebSocket upgrade (chat, live events, voice signaling): + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection \$connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +EOF + hr +} + +print_caddy_snippet() { + snip "Caddy — if you run your OWN Caddy (it auto-handles WebSocket + HTTPS):" + hr + cat << EOF +${DOMAIN} { +EOF + if [[ "$ENABLE_VOICE" == true ]]; then + cat << EOF + handle_path /livekit/* { + reverse_proxy 127.0.0.1:7880 + } + handle { + reverse_proxy 127.0.0.1:${APP_PORT_FINAL} + } +EOF + else + cat << EOF + reverse_proxy 127.0.0.1:${APP_PORT_FINAL} +EOF + fi + cat << EOF + request_body { + max_size ${max_mb}MB + } +} +EOF + hr +} + +print_traefik_snippet() { + snip "Traefik — dynamic (file-provider) config; Traefik handles WebSocket itself:" + hr + cat << EOF +http: + routers: + backspace: + rule: "Host(\`${DOMAIN}\`)" + entryPoints: [websecure] + service: backspace + tls: + certResolver: letsencrypt +EOF + if [[ "$ENABLE_VOICE" == true ]]; then + cat << EOF + backspace-livekit: + rule: "Host(\`${DOMAIN}\`) && PathPrefix(\`/livekit\`)" + entryPoints: [websecure] + service: backspace-livekit + priority: 100 + middlewares: [strip-livekit] + tls: + certResolver: letsencrypt + middlewares: + strip-livekit: + stripPrefix: + prefixes: ["/livekit"] +EOF + fi + cat << EOF + services: + backspace: + loadBalancer: + servers: + - url: "http://127.0.0.1:${APP_PORT_FINAL}" +EOF + if [[ "$ENABLE_VOICE" == true ]]; then + cat << EOF + backspace-livekit: + loadBalancer: + servers: + - url: "http://127.0.0.1:7880" +EOF + fi + hr +} + +print_tunnel_snippet() { + snip "Cloudflare Tunnel — ingress rule (cloudflared config.yml):" + hr + cat << EOF +tunnel: +credentials-file: /root/.cloudflared/.json + +ingress: + - hostname: ${DOMAIN} + service: http://127.0.0.1:${APP_PORT_FINAL} + - service: http_status:404 +EOF + hr + echo " Then map the hostname to the tunnel (once):" + echo -e " ${BOLD}cloudflared tunnel route dns ${DOMAIN}${NC}" +} + # ── Phase 8: Summary ─────────────────────────────────────── step "Backspace is running" echo -e " ${BOLD}URL:${NC} https://${DOMAIN}" echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}" +echo -e " ${BOLD}Mode:${NC} ${DEPLOY_MODE}" echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)" -case "$https_status" in - live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;; - pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;; -esac -echo "" -if [[ "$https_status" == "pending" ]]; then - echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}" - echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}" - echo " 1. ${DOMAIN} resolves to THIS host's public IP" - echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet" - echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}" +if [[ "$DEPLOY_MODE" == "allinone" ]]; then + case "$https_status" in + live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;; + pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;; + esac echo "" - echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}" -else - echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}" + if [[ "$https_status" == "pending" ]]; then + echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}" + echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}" + echo " 1. ${DOMAIN} resolves to THIS host's public IP" + echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet" + echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}" + echo "" + echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}" + else + echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}" + fi + + echo "" + # Best-effort primary LAN IP (the address a router would port-forward to). + LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 || true) + echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud" + echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also" + echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:" + echo "" + echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)" + echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)" + if [[ "$ENABLE_VOICE" == true ]]; then + echo " 3478/UDP TURN — WebRTC NAT traversal (voice)" + echo " 7881/TCP WebRTC TCP fallback (voice)" + echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)" + fi + echo "" + echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}" + if [[ "$ENABLE_VOICE" == true ]]; then + echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}" + fi + echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it." + +elif [[ "$DEPLOY_MODE" == "proxy" ]]; then + echo "" + echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only — never expose it directly)." + echo -e " Point your reverse proxy at it, then open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)." + echo "" + echo -e " ${BOLD}Paste one of these into your reverse proxy${NC} (WebSocket, X-Forwarded-*, and body cap already set):" + echo "" + print_nginx_snippet + echo "" + print_caddy_snippet + echo "" + print_traefik_snippet + echo "" + echo -e " ${BOLD}Nginx Proxy Manager / other GUI proxies:${NC} see the field-by-field guide" + echo -e " in the README → 'Deployment modes' (forward to 127.0.0.1:${APP_PORT_FINAL}, enable" + echo -e " 'Websockets Support', and set client_max_body_size ${max_mb}m in the Advanced tab)." + if [[ "$ENABLE_VOICE" == true ]]; then + echo "" + echo -e " ${YELLOW}Voice is enabled — in addition to the /livekit route above, open these UDP/TCP${NC}" + echo -e " ${YELLOW}media ports on the firewall and forward them to this host:${NC}" + echo " 3478/UDP TURN — WebRTC NAT traversal" + echo " 7881/TCP WebRTC TCP fallback" + echo " 50000-60000/UDP WebRTC media — voice / video / screen-share" + echo -e " ${YELLOW}Media flows host→client directly, NOT through your reverse proxy.${NC}" + fi + +elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then + echo "" + echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only). Your tunnel fronts it." + echo -e " Once the ingress rule is live, open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)." + echo "" + print_tunnel_snippet + echo "" + echo -e " ${YELLOW}Upload cap:${NC} MAX_UPLOAD_SIZE is set to ${max_mb}MB to stay under Cloudflare's 100MB" + echo -e " request-body limit. Raising it above ~100MB will make large uploads fail at the edge." + echo "" + echo -e " ${YELLOW}Voice/video is not available over a tunnel${NC} — WebRTC/UDP media can't traverse it." + echo -e " If you need voice, run Backspace behind a reverse proxy (proxy mode) with the" + echo -e " media ports opened, or use All-in-One with ports 80/443." fi + echo "" -echo -e " ${BOLD}Commands:${NC}" +echo -e " ${BOLD}Commands${NC} (run from this directory):" echo " docker compose logs -f # Watch logs" echo " docker compose restart # Restart all services" echo " docker compose down # Stop everything" -echo " docker compose up -d --build # Rebuild after code changes" - -echo "" -# Best-effort primary LAN IP (the address a router would port-forward to). -LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 || true) -echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud" -echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also" -echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:" -echo "" -echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)" -echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)" -if [[ "$ENABLE_VOICE" == true ]]; then - echo " 3478/UDP TURN — WebRTC NAT traversal (voice)" - echo " 7881/TCP WebRTC TCP fallback (voice)" - echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)" -fi -echo "" -echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}" -if [[ "$ENABLE_VOICE" == true ]]; then - echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}" -fi -echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it." - +echo " docker compose pull && docker compose up -d # Update to the latest prebuilt image" echo ""