From 617999500b51344f19eff8f2da8c9c64abca8911 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:51:51 +0100 Subject: [PATCH] feat: all-in-one production deployment with install script Redesign deployment as a single docker-compose with Backspace, Caddy (auto-HTTPS), and LiveKit (voice/video) using hybrid networking: Backspace+Caddy on isolated bridge, LiveKit on host mode for WebRTC. - Add install.sh: interactive installer that handles Docker setup, domain/DNS verification, secret generation, LiveKit config, and deployment with health-check wait - Add Caddyfile: static reverse proxy config using Caddy env vars, routes /livekit/* to host-mode LiveKit via host.docker.internal - Rewrite docker-compose.yml: all-in-one with profiles (voice), no external volumes/networks, bind mount ./data for visibility - Fix livekit.ts: use LIVEKIT_URL env var directly instead of Host-header derivation that made the env var dead code - Fix Dockerfile: health check reads $PORT dynamically - Update .env.example: add DOMAIN, COMPOSE_PROFILES documentation - Update .gitignore: add livekit.yaml (contains secrets) --- .env.example | 34 +- .gitignore | 3 + Caddyfile | 12 + Dockerfile | 6 +- docker-compose.yml | 69 ++++- install.sh | 428 ++++++++++++++++++++++++++ packages/server/src/routes/livekit.ts | 5 +- 7 files changed, 523 insertions(+), 34 deletions(-) create mode 100644 Caddyfile create mode 100755 install.sh diff --git a/.env.example b/.env.example index 5f7433ff..94095e55 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,30 @@ -# Backspace Configuration -# Copy this file to .env and fill in the values +# ─── Backspace Configuration ──────────────────────────────── +# Copy to .env and configure, or run ./install.sh to generate automatically. -# Server +# Your server's public domain name (required) +DOMAIN=example.com + +# ─── Server ───────────────────────────────────────────────── PORT=3000 HOST=0.0.0.0 -# Authentication — generate a random secret: openssl rand -hex 32 -JWT_SECRET=change_me_to_a_random_64_char_hex_string +# Authentication — generate with: openssl rand -hex 32 +JWT_SECRET= -# LiveKit Voice/Video (optional — leave empty to disable voice features) +# Registration — set to false to close signups after initial setup +REGISTRATION_OPEN=true + +# Max file upload size in bytes (default: 100MB) +MAX_UPLOAD_SIZE=104857600 + +# ─── LiveKit Voice/Video ─────────────────────────────────── +# To enable voice/video, fill in all three values below and add: +# COMPOSE_PROFILES=voice +# Leave empty to run Backspace without voice features. LIVEKIT_URL= LIVEKIT_API_KEY= LIVEKIT_API_SECRET= -# Storage -UPLOAD_DIR=./data/uploads -DB_PATH=./data/backspace.db -MAX_UPLOAD_SIZE=104857600 - -# Registration — set to false to disable new user registration -REGISTRATION_OPEN=true +# ─── Docker Compose ──────────────────────────────────────── +# Uncomment to enable the LiveKit service: +# COMPOSE_PROFILES=voice diff --git a/.gitignore b/.gitignore index bd6c04da..73708b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ data/ .env.local .env.*.local +# Generated deployment config (contains secrets) +livekit.yaml + # OS files .DS_Store Thumbs.db diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 00000000..ed8dae9a --- /dev/null +++ b/Caddyfile @@ -0,0 +1,12 @@ +# Backspace — Caddy reverse proxy configuration +# DOMAIN is read from the container environment (set via docker-compose.yml) + +{$DOMAIN} { + # LiveKit signaling — strip /livekit prefix, forward to host-mode LiveKit + handle_path /livekit/* { + reverse_proxy host.docker.internal:7880 + } + + # Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace" + reverse_proxy backspace:3000 +} diff --git a/Dockerfile b/Dockerfile index 36e8d566..af5f99dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,9 +75,9 @@ ENV UPLOAD_DIR=/app/data/uploads EXPOSE 3000 -# Health check -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD node -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" +# Health check — reads PORT from environment so it works with any configured port +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \ + CMD node -e "fetch('http://localhost:' + (process.env.PORT || 3000) + '/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" # Run the server using tsx from the server package directory WORKDIR /app/packages/server diff --git a/docker-compose.yml b/docker-compose.yml index e553d8b1..1f9d157a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,20 @@ +# ============================================================ +# Backspace — All-in-one production deployment +# ============================================================ +# Run ./install.sh for first-time setup, or configure manually: +# 1. Copy .env.example to .env and fill in values +# 2. Generate livekit.yaml (if enabling voice) +# 3. docker compose up -d --build +# ============================================================ + services: + # ── Backspace application server ────────────────────────── backspace: build: . container_name: backspace restart: unless-stopped - ports: - - "${PORT:-3000}:${PORT:-3000}" volumes: - - backspace-data:/app/data + - ./data:/app/data env_file: - .env environment: @@ -14,20 +22,53 @@ services: - DB_PATH=/app/data/backspace.db - UPLOAD_DIR=/app/data/uploads networks: - - backspace-net + - internal healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:${PORT:-3000}/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:' + (process.env.PORT || 3000) + '/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s - retries: 3 - start_period: 10s + retries: 5 + start_period: 30s -volumes: - backspace-data: - name: backspace-data - external: true + # ── Caddy reverse proxy (auto-HTTPS) ───────────────────── + caddy: + image: caddy:2-alpine + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + - DOMAIN=${DOMAIN:?Set DOMAIN in .env} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + - internal + depends_on: + backspace: + condition: service_healthy + + # ── LiveKit voice/video server ──────────────────────────── + # Activated by COMPOSE_PROFILES=voice in .env + livekit: + image: livekit/livekit-server:latest + container_name: livekit + restart: unless-stopped + network_mode: host + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + command: --config /etc/livekit.yaml + profiles: + - voice networks: - backspace-net: - name: backspace-net - external: true + internal: + driver: bridge + +volumes: + caddy-data: + caddy-config: diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..4694f341 --- /dev/null +++ b/install.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# ============================================================ +# Backspace — Production Installer +# ============================================================ +# Sets up Backspace with Caddy (auto-HTTPS) and optional +# LiveKit (voice/video) on a Linux server. +# +# Usage: +# ./install.sh Interactive setup +# DOMAIN=chat.example.com ./install.sh Non-interactive +# ============================================================ + +set -euo pipefail + +# ── Output helpers ────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN} OK ${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERR]${NC} $*" >&2; } + +step() { + echo "" + echo -e "${BOLD}${CYAN}─── $* ───${NC}" + echo "" +} + +# Track whether we need sudo for docker commands +DOCKER="docker" +COMPOSE="docker compose" + +# ── Phase 1: Prerequisites ───────────────────────────────── + +step "Checking prerequisites" + +# Must be Linux — host networking (LiveKit) is Linux-only +if [[ "$(uname -s)" != "Linux" ]]; then + error "Backspace production deployment requires Linux." + error "Detected: $(uname -s)" + error "For development, use: pnpm dev" + exit 1 +fi +success "Linux detected" + +# Check Docker +if ! command -v docker &>/dev/null; then + warn "Docker is not installed." + read -rp "Install Docker now? [Y/n] " yn + if [[ "${yn,,}" == "n" ]]; then + error "Docker is required. Install it and re-run this script." + exit 1 + fi + info "Installing Docker via official script..." + curl -fsSL https://get.docker.com | sh + sudo systemctl enable --now docker 2>/dev/null || true + sudo usermod -aG docker "$USER" + warn "Added $USER to docker group. You may need to log out and back in." + # Use sudo for the rest of this session + DOCKER="sudo docker" + COMPOSE="sudo docker compose" +else + # Check if current user can talk to Docker daemon + if ! docker info &>/dev/null 2>&1; then + if sudo docker info &>/dev/null 2>&1; then + DOCKER="sudo docker" + COMPOSE="sudo docker compose" + else + error "Cannot connect to Docker daemon." + error "Is Docker running? Try: sudo systemctl start docker" + exit 1 + fi + fi + success "Docker $(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo 'installed')" +fi + +# Check Docker Compose +if ! $COMPOSE version &>/dev/null 2>&1; then + error "Docker Compose plugin is not installed." + error "Install: https://docs.docker.com/compose/install/linux/" + exit 1 +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 +} + +# 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 + fi + success "Ports 80 and 443 are available" +else + success "Caddy is already running (upgrade mode)" +fi + +# Disk space check +available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}') +if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then + warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)" +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." +fi + +# Helper: read existing .env value +env_val() { + if [[ -f .env ]]; then + grep "^${1}=" .env 2>/dev/null | head -1 | cut -d= -f2- + fi +} + +# ── 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 + DOMAIN="${DOMAIN:-$existing_domain}" +else + read -rp "Domain (e.g., chat.example.com): " DOMAIN +fi + +if [[ -z "${DOMAIN:-}" ]]; then + error "Domain is required. Set it via the DOMAIN environment variable or enter it interactively." + exit 1 +fi + +# DNS verification +info "Verifying DNS for ${DOMAIN}..." +resolved_ip="" +if command -v dig &>/dev/null; then + resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1) +elif command -v getent &>/dev/null; then + resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1) +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." +else + success "${DOMAIN} resolves to ${resolved_ip:-verified}" +fi + +# ── Voice/Video ───────────────────────────────────────────── + +existing_profiles=$(env_val COMPOSE_PROFILES) +if [[ -n "${ENABLE_VOICE:-}" ]]; then + # Non-interactive + : +elif [[ "$existing_profiles" == *"voice"* ]]; then + read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn + ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true) +else + read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn + ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true) +fi +ENABLE_VOICE="${ENABLE_VOICE:-true}" + +# ── Instance Name ─────────────────────────────────────────── + +existing_name=$(env_val INSTANCE_NAME) +if [[ -z "${INSTANCE_NAME:-}" ]]; then + read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME + INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}" +fi + +# ── Phase 3: Generate Secrets ─────────────────────────────── + +step "Generating configuration" + +# Preserve existing secrets, generate new ones where missing +JWT_SECRET=$(env_val JWT_SECRET) +if [[ -z "$JWT_SECRET" || "$JWT_SECRET" == "change_me"* ]]; then + JWT_SECRET=$(openssl rand -hex 32) + info "Generated new JWT_SECRET" +else + info "Preserved existing JWT_SECRET" +fi + +LIVEKIT_API_KEY=$(env_val LIVEKIT_API_KEY) +LIVEKIT_API_SECRET=$(env_val LIVEKIT_API_SECRET) +if [[ "$ENABLE_VOICE" == true ]]; then + if [[ -z "$LIVEKIT_API_KEY" ]]; then + LIVEKIT_API_KEY="API$(openssl rand -hex 8)" + info "Generated new LIVEKIT_API_KEY" + else + info "Preserved existing LIVEKIT_API_KEY" + fi + if [[ -z "$LIVEKIT_API_SECRET" ]]; then + LIVEKIT_API_SECRET=$(openssl rand -hex 24) + info "Generated new LIVEKIT_API_SECRET" + else + info "Preserved existing LIVEKIT_API_SECRET" + fi +fi + +# ── Phase 4: Write Configuration Files ───────────────────── + +step "Writing configuration files" + +# ── .env ──────────────────────────────────────────────────── + +cat > .env << EOF +# Backspace Configuration +# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") + +DOMAIN=${DOMAIN} + +# Server +PORT=3000 +HOST=0.0.0.0 + +# Authentication +JWT_SECRET=${JWT_SECRET} + +# Registration +REGISTRATION_OPEN=true + +# Max upload size in bytes (100MB) +MAX_UPLOAD_SIZE=104857600 +EOF + +if [[ "$ENABLE_VOICE" == true ]]; then + cat >> .env << EOF + +# LiveKit Voice/Video +LIVEKIT_URL=wss://${DOMAIN}/livekit +LIVEKIT_API_KEY=${LIVEKIT_API_KEY} +LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} + +# Activate the LiveKit service in Docker Compose +COMPOSE_PROFILES=voice +EOF +else + cat >> .env << EOF + +# LiveKit Voice/Video (disabled) +# To enable: fill in credentials and add COMPOSE_PROFILES=voice +# LIVEKIT_URL=wss://${DOMAIN}/livekit +# LIVEKIT_API_KEY= +# LIVEKIT_API_SECRET= +EOF +fi + +success ".env" + +# ── livekit.yaml ──────────────────────────────────────────── + +# Always generate livekit.yaml so docker-compose config doesn't complain +# about a missing bind mount if someone inspects the full compose file. +if [[ "$ENABLE_VOICE" == true ]]; then + cat > livekit.yaml << EOF +# LiveKit Server Configuration +# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") + +port: 7880 + +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 60000 + use_external_ip: true + +turn: + enabled: true + domain: ${DOMAIN} + udp_port: 3478 + +keys: + ${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET} + +room: + auto_create: true + empty_timeout: 300 + departure_timeout: 20 + +logging: + level: info +EOF + success "livekit.yaml" +else + cat > livekit.yaml << EOF +# LiveKit is disabled. Run install.sh and enable voice to configure. +port: 7880 +keys: {} +logging: + level: info +EOF + info "livekit.yaml (placeholder — voice disabled)" +fi + +# ── Data directory ────────────────────────────────────────── + +mkdir -p ./data/uploads +success "data/" + +# ── Phase 5: Migrate legacy Docker volume (if present) ───── + +if $DOCKER volume inspect backspace-data &>/dev/null 2>&1; then + if [[ ! -f ./data/backspace.db ]]; then + step "Migrating data from legacy Docker volume" + info "Copying backspace-data volume contents to ./data ..." + $DOCKER run --rm \ + -v backspace-data:/source:ro \ + -v "$(pwd)/data:/target" \ + alpine sh -c "cp -a /source/. /target/" + success "Data migrated from backspace-data volume to ./data" + info "The old volume is still intact. Remove it with: docker volume rm backspace-data" + fi +fi + +# Also clean up legacy external network if it exists (no longer needed) +if $DOCKER network inspect backspace-net &>/dev/null 2>&1; then + info "Legacy backspace-net network detected. It is no longer needed." + info "Remove after verifying: docker network rm backspace-net" +fi + +# ── Phase 6: Build & Deploy ──────────────────────────────── + +step "Deploying Backspace" + +info "Building Backspace image (this may take a few minutes on first run)..." +$COMPOSE build --quiet + +info "Starting services..." +$COMPOSE up -d + +# Wait for health check +info "Waiting for Backspace to become healthy..." +healthy=false +for i in $(seq 1 60); do + status=$($DOCKER inspect backspace --format '{{.State.Health.Status}}' 2>/dev/null || echo "waiting") + if [[ "$status" == "healthy" ]]; then + healthy=true + break + fi + sleep 2 +done + +if [[ "$healthy" == true ]]; then + success "Backspace is healthy" +else + warn "Health check hasn't passed yet. Check logs: docker compose logs backspace" +fi + +# ── Phase 7: Set instance name ────────────────────────────── + +if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspace" ]]; then + $DOCKER exec -w /app backspace node -e " + const Database = require('better-sqlite3'); + const db = new Database('/app/data/backspace.db'); + const changes = db.prepare('UPDATE instance_settings SET instance_name = ? WHERE id = 1').run('${INSTANCE_NAME}').changes; + db.close(); + if (changes === 0) { console.error('No rows updated'); process.exit(1); } + " 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)" +fi + +# ── Phase 8: Summary ─────────────────────────────────────── + +step "Backspace is running" + +echo -e " ${BOLD}URL:${NC} https://${DOMAIN}" +echo -e " ${BOLD}Admin:${NC} admin / admin123" +echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}" +echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)" +echo "" +echo -e " ${YELLOW}Change the admin password immediately after first login.${NC}" +echo "" +echo -e " ${BOLD}Commands:${NC}" +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" + +if [[ "$ENABLE_VOICE" == true ]]; then + echo "" + echo -e " ${BOLD}Firewall — open these ports for voice/video:${NC}" + echo " 3478/UDP TURN (NAT traversal)" + echo " 7881/TCP WebRTC TCP fallback" + echo " 50000-60000/UDP WebRTC media" +fi + +echo "" diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index 83755617..c94fb4f6 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -55,10 +55,7 @@ export async function livekitRoutes(app: FastifyInstance): Promise { const jwt = await token.toJwt(); - const requestHost = request.headers.host?.replace(/:\d+$/, '') || ''; - const livekitUrl = requestHost - ? `wss://${requestHost}/livekit` - : (config.livekit.url ?? ''); + const livekitUrl = config.livekit.url ?? ''; const response: LiveKitTokenResponse = { token: jwt,