SpipCP

Installation

How to install SpipCP on a Linux server and reach the panel.

Not sure which setup you want?

Start at Choose your setup — self-hosted DNS vs. an external provider (Cloudflare, Bunny, …), and where this page fits. This page is the standard install for when your panel hostname already resolves.

Prerequisites

RequirementMinimumRecommended
OSUbuntu 24.04 LTSUbuntu 24.04 LTS
CPU1 vCPU2 vCPU
RAM1 GB2 GB
Disk10 GB20 GB
Docker26+29+
DomainRequired (for TLS)

Quick install

Clone the repo, run the installer, answer its prompts. It installs Docker, fills in your .env (it can generate the secrets for you), and starts everything:

git clone https://github.com/spipov/spipcp.git && cd spipcp
./install.sh

That's the whole install. Open https://<your-domain>/ and create the first account — it becomes the admin.

What happens automatically

On every install and upgrade: the database migrates itself (a one-shot migrate service — Exited (0) in docker compose ps is correct, not an error), roles & permissions seed themselves, and agent releases are baked into the image and auto-published — the first node enrollment never asks you to compile or upload anything. To verify a boot: curl -fsS https://<your-domain>/healthz and /login should both return 200 — healthz alone can be green while the app itself is broken.

Save your MASTER_KEY

The installer shows a generated MASTER_KEY once. Copy it somewhere safe (a password manager) — backups are encrypted with it, so without it no backup can be restored. More on recovery.

Cloning from another git host

The git clone above uses GitHub, but there's no hardcoded origin — clone from wherever you keep the repo. Swap in your URL:

git clone https://gitlab.com/spipov/spipcp.git              # GitLab
git clone https://git.example.com/spipov/spipcp.git         # self-hosted Gitea / Forgejo
git clone git@github.com:spipov/spipcp.git                  # private, via SSH key
git clone --branch v1.0.0 https://github.com/spipov/spipcp.git   # a specific release

Editing .env yourself

Prefer to set the values by hand instead of answering prompts? Copy the template and edit it:

cp .env.example .env
nano .env        # or: vi .env

In nano, edit then save with Ctrl-O, Enter, and quit with Ctrl-X. Generate each secret with openssl rand -hex 32 and paste it in. You must set at least: MASTER_KEY, BETTER_AUTH_SECRET, AGENT_GATEWAY_SECRET, POSTGRES_PASSWORD, your SITE_ADDRESS (the panel's domain), TLS_MODE (you@example.com for real TLS, or internal for a local box), and BETTER_AUTH_URL=https://<SITE_ADDRESS> — auth validates the browser's Origin against it, so the .env.example localhost default means every login 403s "Invalid origin" even though the page renders (./install.sh --check now fails loudly on that mismatch). See the full reference below.

Then check it and start:

./install.sh --check     # validates .env and tells you exactly what's wrong, if anything
docker compose up -d     # start the stack

Set a real SPIPCP_TAG before you run compose directly

docker compose up -d only pulls a published image when SPIPCP_TAG resolves to one — set it to latest or a specific release (e.g. v1.1.0), not the local placeholder in .env.example. local tells compose to build the image on this box instead (the panel service also declares a build: block for that reason), which is slow or outright fails on a minimal VPS with no build toolchain. ./install.sh (above) always resolves SPIPCP_TAG to a real tag for you and never builds — prefer it for a fresh production install. This manual docker compose up -d path is the advanced/alternative route, for when you're editing .env by hand or already have a checked-out repo on a box with a toolchain.

Unattended install

For CI or cloud-init, pass every value up front and add --yes so nothing prompts:

SITE_ADDRESS=panel.example.com TLS_MODE=you@example.com \
MASTER_KEY="$(openssl rand -hex 32)" BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \
AGENT_GATEWAY_SECRET="$(openssl rand -hex 32)" POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
  ./install.sh --yes

Boot configuration

The full .env reference — the same content as .env.example. Replace every change-me-… placeholder before going to production:

.env
# SpipCP — environment variables
# Copy to .env and fill in your values.
# Dev defaults below match docker-compose.dev.yml.

# ── Database ──────────────────────────────────────────────────────────────────
# Dev: postgres container (docker-compose.dev.yml) on host :3432.
# To use your own host Postgres instead, change the port to 5432.
DATABASE_URL=postgresql://spipcp:spipcp@localhost:3432/spipcp

# ── Panel & worker ports ──────────────────────────────────────────────────────
PORT=3400
WORKER_PORT=3401
NODE_ENV=development

# ── Auth (better-auth) ────────────────────────────────────────────────────────
# Prod: generate with `openssl rand -hex 32`; 32 chars minimum
BETTER_AUTH_SECRET=change-me-32-chars-minimum-do-not-ship
BETTER_AUTH_URL=http://localhost:3400
# Extra origins allowed to sign in/up (comma-separated). Keep BETTER_AUTH_URL on
# localhost and add a tunnel/public URL here so both origins work at once.
BETTER_AUTH_TRUSTED_ORIGINS=

# ── Envelope encryption (the one master key — SECURITY §1 / ARCHITECTURE §7.1) ──
# 32-byte hex (`openssl rand -hex 32`). Encrypts everything sensitive at rest:
# secrets table, SMTP/DNS provider tokens, agent enroll keys. Prod boot refuses if
# unset, a placeholder, or shorter than 32 chars (a real `openssl rand -hex 32` is
# 64 hex chars). (2FA TOTP secrets are encrypted by better-auth via
# BETTER_AUTH_SECRET; MASTER_KEY is the app's own field-encryption path, Task D.)
MASTER_KEY=change-me-master-key-do-not-ship-0000000000000000000000000000000000

# ── SMTP ──────────────────────────────────────────────────────────────────────
# These only PRE-FILL the Settings → Email form on first boot; the saved DB row is
# authoritative. Set them to YOUR mail server — no value here is special.
# Dev: the operator's own local SMTP app (e.g. smtp.localhost:1025 / 127.0.0.1:1025)
# Rig automated runs: mailpit at localhost:3416 (--profile rig)
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
# Username/From = a real, sendable mailbox on YOUR server (e.g. support@your-dev-domain).
# Left blank so nothing fake is seeded — fill in the UI or here.
SMTP_USER=
SMTP_PASS=
SMTP_FROM=

# Object storage, ACME/TLS issuers, and git hosts are NOT configured here — they're set
# in the panel UI (Backups → Targets, Networking → SSL, Settings → Git accounts) and
# stored encrypted in the DB. Keep .env to the boot essentials only.

# ── Agent gateway ─────────────────────────────────────────────────────────────
# Secret used by the agent to authenticate to the worker gateway
# Generate with `openssl rand -hex 32`
AGENT_GATEWAY_SECRET=change-me-agent-secret
# Worker gateway TLS: "on" (wss, dev self-signed) locally; "off" in prod where the
# worker sits behind Caddy and serves plain ws (Caddy terminates the public TLS).
WORKER_TLS=on
# The wss URL an enrolled agent connects back to. Leave UNSET locally — the panel
# auto-detects this host's primary LAN IP, so a VM node reaches the panel without
# hand-editing a DHCP-changeable IP. Set it to pin a value, or to a public hostname
# in prod (e.g. wss://panel.example.com).
# WORKER_WSS_URL=
# Base URL the panel hosts published agent binaries at (the URL baked into each release;
# fetched by the panel itself AND by nodes). Leave UNSET locally — auto-detects this host's
# LAN IP on the panel PORT. In prod set it to the public panel hostname (behind Caddy),
# e.g. https://panel.example.com.
# AGENT_BINARY_URL=

# ── Dev tunnel (test REAL servers against your LOCAL panel — no rebuilds) ──────
# Run the panel natively (`pnpm dev:all`, instant reload) but let a real VPS/bare-metal
# server dial home, via a Cloudflare tunnel → the dev Caddy edge → your local worker.
# Full setup: runbooks/dev-tunnel.md. Set these three ONLY for a tunnel session; unset =
# pure-local LAN dev (the default). Replace dev.your-domain.com with your hostname.
#   WORKER_TLS=off                                   # worker serves plain ws behind Caddy
#   WORKER_WSS_URL=wss://dev.your-domain.com/ws      # the URL the agent dials home on
#   AGENT_BINARY_URL=https://dev.your-domain.com     # where nodes fetch the agent binary
#   BETTER_AUTH_URL=https://dev.your-domain.com      # auth origin matches the public host

# ── Prod compose (docker-compose.yml) ─────────────────────────────────────────
# Caddy edge site + TLS. Local 00-D drill: localhost + internal CA (curl -k).
# Prod: SITE_ADDRESS=panel.example.com, TLS_MODE=you@example.com (real Let's Encrypt).
SITE_ADDRESS=localhost
TLS_MODE=internal
# Registry+repo the panel/worker image is pulled from (the part before the `:tag`).
# Default = the public GHCR image. Point it at a self-hosted registry to pull from
# your own: Gitea/Forgejo (git.example.com/spipov/spipcp), Harbor, GitLab registry,
# or any OCI registry. The box never builds — it only pulls whatever this resolves to.
SPIPCP_IMAGE=ghcr.io/spipov/spipcp
# Image tag pulled from GHCR (CI pushes real tags). `latest` tracks the newest release; pin a
# specific version (e.g. v1.1.0) for a controlled upgrade. `local` is a special value that BUILDS
# the image on this machine instead of pulling — dev-box only, never for a production install.
SPIPCP_TAG=latest
# Postgres password for the prod db service (compose derives DATABASE_URL from it).
POSTGRES_PASSWORD=spipcp
# Prod compose passes MASTER_KEY (above) + POSTGRES_PASSWORD into the panel/worker services so the
# envelope key + the panel self-backup (Phase 09.8) work. These four — MASTER_KEY, BETTER_AUTH_SECRET,
# AGENT_GATEWAY_SECRET, POSTGRES_PASSWORD — are the "4 boot vars" the DR runbook restores out of band
# (runbooks/migrate-panel.md). Generate each real one with `openssl rand -hex 32`.

Object storage, ACME/TLS, and git hosts used to live here as env vars; they're now configured entirely in the panel UI — backup storage targets under Backups → Targets, certificate issuers under Networking → SSL, and git provider accounts under Settings → Git accounts — and stored encrypted in the database, so .env stays limited to the boot essentials.

The four you must set:

VariableWhat it is
MASTER_KEYEncrypts everything secret at rest. Treat it like a root key — if you rotate it, old secrets can't be decrypted.
BETTER_AUTH_SECRETSigns login sessions.
POSTGRES_PASSWORDThe database password.
AGENT_GATEWAY_SECRETShared secret for the agent gateway.
SITE_ADDRESSYour panel's domain (e.g. panel.example.com).

Use your own registry

By default the image is pulled from ghcr.io/spipov/spipcp. To pull from your own registry instead — a self-hosted Gitea/Forgejo, Harbor, GitLab, or your own GHCR — set SPIPCP_IMAGE:

.env
SPIPCP_IMAGE=git.example.com/spipov/spipcp
SPIPCP_TAG=v1.0.0

If the registry needs a login, run docker login <your-registry> once first. The server only ever pulls the image — it never builds it.

Updating the panel

In the panel, go to Settings → System → Updates and press Pull & restart. It pulls the configured image and restarts — no SSH needed. The panel goes offline for a moment, then comes back on the new version.

To move to a different version, set the new tag in .env first, then press the button:

.env
SPIPCP_TAG=v1.1.0   # then: Settings → System → Updates → Pull & restart

On the box instead?

Re-running install.sh with a new SPIPCP_TAG does the same thing as the button. (Restoring from a backup is separate — see disaster recovery.)

Next steps

  • Setup wizard — first boot: create the admin, check MASTER_KEY, seed SMTP.
  • Concepts — how auth, roles, sessions, and settings fit together.
  • What is SpipCP? — architecture overview and design principles.

On this page