name: proxmate services: backend: build: context: ./backend image: proxmate-backend restart: unless-stopped # Hardening: drop all Linux capabilities, then add back only the few the # entrypoint needs. docker-entrypoint.sh starts as root to make the # (root-owned) data volume writable by the unprivileged `node` user — # `chown -R node:node /data` (CHOWN, plus DAC_OVERRIDE to traverse) — then # `su-exec node:node` (SETUID/SETGID) drops to it. Without these the chown # fails ("Operation not permitted") and the backend crash-loops. They are # used only during the brief root phase; once it re-execs as `node`, the # process holds no capabilities. Also block setuid-binary privilege escalation. cap_drop: - ALL cap_add: - CHOWN - DAC_OVERRIDE - SETUID - SETGID security_opt: - no-new-privileges:true environment: PORT: "4000" NODE_ENV: production # SQLite lives on the named volume so data survives container rebuilds. DATABASE_URL: file:/data/proxmate.db # Where app-database snapshots are written INSIDE the container. Paired # with the ${PROXMATE_BACKUP_DIR} mount below; change the host side there, # not here. Used as the default for the in-app setting. APPDB_BACKUP_DIR: /var/backups/proxmate # Used for the CORS allow-list — set to your public frontend origin. FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} # REQUIRED and must be stable across restarts (losing it makes stored # secrets unreadable). Generate once with: openssl rand -hex 32 ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env (openssl rand -hex 32)} # Send Secure cookies (HTTPS only). MUST be "true" in production — passkeys # and SameSite cookies require a secure context. COOKIE_SECURE: ${COOKIE_SECURE:-false} # Number of reverse-proxy hops in front of the API, so the real client IP # is used for rate-limiting + audit. 1 when behind a single proxy (Caddy/nginx). TRUST_PROXY: ${TRUST_PROXY:-0} # Header carrying the real client IP for the audit log. Unset defaults to # "cf-connecting-ip" (Cloudflare); behind a plain Caddy/nginx proxy set # REAL_IP_HEADER=x-forwarded-for in .env. Deliberately `-` not `:-`: an # explicitly-EMPTY value must pass through (it means "disable, use the raw # socket IP"); only a fully unset var takes the Cloudflare default. The # header is only honoured when TRUST_PROXY >= 1, and x-forwarded-for is # resolved through TRUST_PROXY's hop count rather than read verbatim. REAL_IP_HEADER: ${REAL_IP_HEADER-cf-connecting-ip} # Public base URL of the API — used to build the SSO (OIDC) callback URL # (${BACKEND_PUBLIC_URL}/api/auth/sso/callback). Set to your public origin. BACKEND_PUBLIC_URL: ${BACKEND_PUBLIC_URL:-http://localhost:4000} # WebAuthn (passkey) relying-party. Default to the FRONTEND_URL host; set # explicitly in production. RP ID = registrable domain (no scheme/port), # ORIGIN = the full https:// origin the browser sees. WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-} WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-} # Optional auth rate-limit tuning (defaults: 20 req / 15 min / IP). AUTH_RATE_LIMIT_MAX: ${AUTH_RATE_LIMIT_MAX:-20} AUTH_RATE_LIMIT_WINDOW_MS: ${AUTH_RATE_LIMIT_WINDOW_MS:-900000} # Optional VM-share throttle (defaults: 30 share requests / hour / signed-in user). SHARE_RATE_LIMIT_MAX: ${SHARE_RATE_LIMIT_MAX:-30} SHARE_RATE_LIMIT_WINDOW_MS: ${SHARE_RATE_LIMIT_WINDOW_MS:-3600000} # Optional guest-agent file tools: pushes per 10 min, install-log reads per # minute (both per signed-in user) and the largest pushable file (max 8 MiB). GUEST_FILE_RATE_LIMIT_MAX: ${GUEST_FILE_RATE_LIMIT_MAX:-30} GUEST_FILE_RATE_LIMIT_WINDOW_MS: ${GUEST_FILE_RATE_LIMIT_WINDOW_MS:-600000} GUEST_LOG_RATE_LIMIT_MAX: ${GUEST_LOG_RATE_LIMIT_MAX:-30} GUEST_FILE_PUSH_MAX_BYTES: ${GUEST_FILE_PUSH_MAX_BYTES:-1048576} # Optional public-token throttle for invite lookups and downloads (defaults: # 60 requests / 15 min / IP). PUBLIC_TOKEN_RATE_LIMIT_MAX: ${PUBLIC_TOKEN_RATE_LIMIT_MAX:-60} PUBLIC_TOKEN_RATE_LIMIT_WINDOW_MS: ${PUBLIC_TOKEN_RATE_LIMIT_WINDOW_MS:-900000} # Minutes before a firing alert rule can fire again (default 60, minimum 5). ALERT_COOLDOWN_MIN: ${ALERT_COOLDOWN_MIN:-60} # Minutes a create/restore/rebuild/backup row must be untouched before the # restart reconciler settles it (default 15, minimum 1). RECONCILE_STUCK_MINUTES: ${RECONCILE_STUCK_MINUTES:-15} # Per-account brute-force lockout (defaults: 10 failed logins → 15 min # auto-unlock). Admins are emailed on lockout when SMTP is configured. AUTH_LOCKOUT_MAX: ${AUTH_LOCKOUT_MAX:-10} AUTH_LOCKOUT_MINUTES: ${AUTH_LOCKOUT_MINUTES:-15} # Keep Proxmox TLS verification ON against a PRIVATE CA instead of disabling # it: mount the CA PEM (docker-compose.override.yml) and point this at it. PROXMOX_CA_CERT_FILE: ${PROXMOX_CA_CERT_FILE:-} # Which GitHub repo the in-app "Check for updates" reads releases from. UPDATE_REPO: ${UPDATE_REPO:-r0073d-l053r/ProxMate} # ── Opt-in one-click self-update ── # The admin "Install update" button only works when this is "true" AND the # host updater (deploy/update.sh + the systemd path unit) is installed. # When enabled, also mount the control dir (docker-compose.override.yml). # See DEPLOYMENT.md. SELF_UPDATE_ENABLED: ${SELF_UPDATE_ENABLED:-false} # /metrics auth: in production the endpoint returns 404 unless this is set; # scrapers must send `Authorization: Bearer `. METRICS_TOKEN: ${METRICS_TOKEN:-} # Opt-in: allow admin-configured webhooks / cloud-image mirrors on private # LAN addresses (SSRF guard escape hatch). Off by default. ALLOW_PRIVATE_OUTBOUND_URLS: ${ALLOW_PRIVATE_OUTBOUND_URLS:-false} # Let ProxMate WRITE cloud-init "extras" snippets on demand instead of an # admin pre-placing every combination. SNIPPET_DIR must ALSO be mounted in # (docker-compose.override.yml) — a path inside the container, on a shared Proxmox # storage's snippets/ dir; SNIPPET_STORAGE is that storage's id. SNIPPET_DIR: ${SNIPPET_DIR:-} SNIPPET_STORAGE: ${SNIPPET_STORAGE:-} # Let tenants download a guest backup through the browser. Like the snippet # dir this is a CONTAINER path and must be mounted in (docker-compose.override.yml). BACKUP_DOWNLOAD_DIR: ${BACKUP_DOWNLOAD_DIR:-} # Cap on a restore-from-upload (GB). Unset uses the built-in default. RESTORE_UPLOAD_MAX_GB: ${RESTORE_UPLOAD_MAX_GB:-} # Schedule overrides for the background jobs (5-field cron). Unset uses the # defaults: guest backups Sundays 03:00, access-expiry sweep hourly at :20. MATESTATE_CRON: ${MATESTATE_CRON:-} ACCESS_EXPIRY_CRON: ${ACCESS_EXPIRY_CRON:-} # Same, for app-database snapshots (default nightly 02:30), the cluster # balancer and the cloud-image template refresh. Invalid or unset values # fall back to the built-in schedules. APPDB_BACKUP_CRON: ${APPDB_BACKUP_CRON:-} BALANCER_CRON: ${BALANCER_CRON:-} TEMPLATE_REFRESH_CRON: ${TEMPLATE_REFRESH_CRON:-} # Weekly usage digest to users who opted in (default Mondays 08:00). USAGE_DIGEST_CRON: ${USAGE_DIGEST_CRON:-} # Comma-separated optional modules to enable (see backend/src/modules/). PROXMATE_MODULES: ${PROXMATE_MODULES:-} # Log verbosity (pino level: fatal, error, warn, info, debug, trace). LOG_LEVEL: ${LOG_LEVEL:-info} # Optional GitHub token for the "Check for updates" call (higher API rate limit). GITHUB_TOKEN: ${GITHUB_TOKEN:-} # Port code-server listens on inside tenant VMs for the ProxMate IDE. IDE_GUEST_PORT: ${IDE_GUEST_PORT:-} # Report healthy only once /api/health passes (DB reachable + migrations run), # so the frontend can wait for a genuinely-ready backend (see depends_on below). healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s start_period: 40s retries: 3 volumes: - proxmate-data:/data # App-database backups (Settings -> Maintenance). ProxMate runs in a # container, so it can only write to paths mounted in from the host — # creating a folder on the host alone does nothing. The container-side # path is fixed; PROXMATE_BACKUP_DIR in .env chooses where on the HOST the # snapshots actually land (default: ./backups next to this file). - ${PROXMATE_BACKUP_DIR:-./backups}:/var/backups/proxmate # Optional mounts (shared snippets dir, guest-backup downloads, the # one-click-update control dir, a private Proxmox CA) do NOT go in this # file: an update checks the new release out over it. Put them in an # untracked docker-compose.override.yml next to this file, which compose # merges in automatically — copy docker-compose.override.example.yml and # uncomment what you need. Don't edit this file locally; the updater # refuses to run over local changes to it rather than discard them. ports: # Bind to 127.0.0.1 in production (BIND_ADDR=127.0.0.1) so only the local # reverse proxy can reach the API; default 0.0.0.0 for simple/dev use. - "${BIND_ADDR:-0.0.0.0}:${BACKEND_PORT:-4000}:4000" frontend: build: context: ./frontend args: # Baked into the client bundle at build time — must be the API URL the # user's browser can reach (e.g. https://proxmate.example.com/api). NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api} # Public origin for SEO metadata (OG image / canonical / manifest URLs). # Must be the externally reachable URL link-preview bots can fetch. NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} image: proxmate-frontend restart: unless-stopped cap_drop: - ALL security_opt: - no-new-privileges:true environment: # Optional: pin the CSP connect-src to your exact API + WebSocket origin, # e.g. "'self' https://proxmate.example.com wss://proxmate.example.com". # Blank = a default of ('self' https: wss:) plus the NEXT_PUBLIC_API_URL origin # and its ws/wss twin. See frontend/src/lib/csp.ts. # In .env this value MUST be wrapped in double quotes, or compose stops # reading at the first closing single quote and you get just `self`. CSP_CONNECT_SRC: ${CSP_CONNECT_SRC:-} # Also passed at runtime for parity with the build arg above. Be aware Next # INLINES NEXT_PUBLIC_* at build time, so server code reads the value that # was baked into the image, not this one — changing it here without a # rebuild has no effect. Kept because it costs nothing and keeps `docker # compose config` honest about the intended origin. NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} depends_on: backend: # Wait for the backend to be healthy (migrated + DB up), not just started, # so the UI never comes up against an unmigrated/booting API. condition: service_healthy ports: - "${BIND_ADDR:-0.0.0.0}:${FRONTEND_PORT:-3000}:3000" volumes: proxmate-data: