SpipCP

Installation

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

Not sure which setup applies?

Start at Choose a setup โ€” self-hosted DNS vs. an external provider (Cloudflare, Bunny, โ€ฆ), and where this page fits. This page is the standard install for when the 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 .env (it can generate the secrets automatically), and starts everything:

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

That's the whole install. Open https://<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 requires compiling or uploading 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 the 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 the repo lives. Swap in the 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 by hand

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. At minimum, set: MASTER_KEY, BETTER_AUTH_SECRET, AGENT_GATEWAY_SECRET, POSTGRES_PASSWORD, SITE_ADDRESS (the panel's domain), TLS_MODE (admin@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 reports exactly what's wrong, if anything
docker compose up -d     # start the stack

Set a real SPIPCP_TAG before running 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 and never builds โ€” prefer it for a fresh production install. This manual docker compose up -d path is the advanced/alternative route, for editing .env by hand or when a checked-out repo already exists 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. In prod point it at the STABLE AGENT GATEWAY โ€”
# the box's public IP (wss://<public-ip>/ws) with PANEL_IP set to the same IP โ€” so
# agents keep one permanent address and the panel hostname stays free to change.
# 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 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 required variables:

VariableWhat it is
MASTER_KEYEncrypts everything secret at rest. Treat it like a root key โ€” rotating it makes old secrets undecryptable.
BETTER_AUTH_SECRETSigns login sessions.
POSTGRES_PASSWORDThe database password.
AGENT_GATEWAY_SECRETShared secret for the agent gateway.
SITE_ADDRESSThe panel's domain (e.g. panel.example.com).

Use a custom registry

By default the image is pulled from ghcr.io/spipov/spipcp. To pull from a private registry instead โ€” a self-hosted Gitea/Forgejo, Harbor, GitLab, or a private 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 <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.)

Which version is running?

The image tag and the product version are two different things, and the changelog is the one that states what the software actually claims to be. See Versioning and the Changelog โ€” the badge at the top of that page shows the version the panel is currently running.

Deploying from source

This is the escape hatch, not the destination

Building on the box needs SSH and does not scale past a single operator. The moment a registry exists โ€” GitHub's, or a self-hosted Forgejo โ€” updates happen from inside the panel with no SSH at all, and this section stops applying. See Shipping updates for all three paths and how to move between them (it is one line of .env).

Running SpipCP from a clone rather than a published image โ€” rsyncing the source to a box and building it there โ€” makes the deploy one command. Tell it which server first, once:

in your SpipCP clone
cp .deploy.env.example .deploy.env     # then set BOX=your.server.ip

That file is gitignored and holds a hostname, nothing else. There is no default server โ€” a hardcoded IP in an open-source repo would be somebody else's box. Credentials stay in the local ssh-agent; the script never handles a key or a password.

Then, from the repo root:

pnpm deploy              # bake the docs, sync, rebuild the image on the box, restart, verify
pnpm deploy --dry-run    # show exactly what would transfer; change nothing
pnpm deploy --sync-only  # bake + sync, then stop โ€” run docker compose on the box separately

Where does `docker compose` happen?

pnpm deploy handles it: it SSHes in and runs docker compose build panel worker && docker compose up -d. The box is never touched directly, and there is nothing to run afterwards.

pnpm deploy --sync-only stops after the sync and prints the ssh and docker compose lines to paste manually โ€” for driving the box directly instead.

pnpm deploy finishes by curling the panel's public endpoints, so a deploy that did not come up is reported as such, instead of printing "Done" and leaving the outcome unknown.

Why the bake is not optional

The docs are a separate repo, and their built output (panel/docs-dist/) is gitignored โ€” so a plain .gitignore-honouring rsync silently drops it and the box keeps serving its old docs. The script bakes on every deploy and forces the build artifacts across. Stale docs after a deploy trace back to this โ€” and it is exactly the trap pnpm deploy exists to absorb.

Nothing is ever nuked. A panel update is an in-place image rebuild: the database volume keeps running, enrolled nodes are untouched, sites keep serving, and migrations are additive.

Finally, in the panel: Admin โ†’ Panel apps โ†’ Publish docs to serve the freshly baked documentation.

The panel's language

The panel is available in seven languages, and it picks one before an account exists: the sign-in page is offered in the browser's language, and a flag menu below the sign-in card overrides it.

After signing in, the choice lives in Account โ†’ Preferences (or the flag menu in the header, beside the theme switcher), and an admin sets the default for everyone in Settings โ†’ Language & region. One choice covers the language of the interface and how dates and numbers are written.

Nothing needs configuring at install time. The translations ship inside the image; the panel makes no outbound request to translate anything.

โ†’ Language & region

Next steps

  • Setup wizard โ€” first boot: create the admin, check MASTER_KEY, seed SMTP.
  • Language & region โ€” the panel in seven languages.
  • Concepts โ€” how auth, roles, sessions, and settings fit together.
  • What is SpipCP? โ€” architecture overview and design principles.

On this page