Shipping updates
The three ways an update reaches a SpipCP box — and how to move between them without changing the panel.
There are three ways a new version of SpipCP reaches a running box. They are not competing designs —
they are the same design at three stages of maturity, and moving between them changes one line of
.env and nothing else.
The reason that works is a decision made early and never bent: the box never builds. It only pulls.
Whatever SPIPCP_IMAGE resolves to — GitHub's registry, a self-hosted Forgejo, Harbor, anything that speaks
OCI — the panel, the updater, and the update button behave identically. The registry is a detail the
panel does not care about.
| Where the image comes from | Who runs the deploy | Needs | |
|---|---|---|---|
| 1. From source | Built on the box | An operator, from a laptop | SSH |
| 2. Public registry | GitHub Actions → GHCR | The operator, from the panel | Nothing |
| 3. Self-hosted registry | Forgejo Actions → a self-hosted instance | The operator, from the panel | Nothing |
Most setups want 2 or 3. 1 is the escape hatch for when neither exists yet.
1. From source (no registry yet)
This is the starting point when no CI and no registry exist yet — the image is built on the box. It needs SSH and does not scale past a single operator, but it works on day one.
Point it at the target server, once:
cp .deploy.env.example .deploy.env # then set BOX=target.server.ipThen, from the repo root:
pnpm deploy # bake the docs, sync, rebuild on the box, restart, verify
pnpm deploy --dry-run # show what would transfer; change nothing
pnpm deploy --sync-only # bake + sync, then stop — docker compose is run on the box separatelyWhy this is the awkward one
The docs are a separate repo, and their built output is gitignored — so a plain rsync silently
drops it and the box keeps serving old docs. That is why the bake is unconditional and why the
rsync has --include rules that must precede the .gitignore filter. pnpm deploy absorbs all of
that. Once a registry exists, this path is no longer needed — every trap on this page belongs to it.
SPIPCP_TAG=local is the flag that says "build here instead of pulling". It is a dev-box value. A
real install never uses it.
2. A public registry (GitHub → GHCR)
The moment CI publishes an image, the deploy stops being a manual action and becomes something the operator does — from inside the panel, with no SSH and no source on the server.
Set it up once. .github/workflows/panel-image.yml already exists in the repo. It triggers on a
version tag:
git tag v1.5.0 && git push origin v1.5.0That builds the panel image for amd64 + arm64, bakes the docs into it (it checks out the docs repo
first — an image without that ships with no documentation), and pushes 1.5.0 and latest to
ghcr.io/<org>/spipcp.
On the box, .env already points there by default:
SPIPCP_IMAGE=ghcr.io/spipov/spipcp
SPIPCP_TAG=latestThe operator updates from the panel: Settings → System → Updates → Pull & restart. The panel
drops a trigger file; the updater sidecar runs docker compose pull && up -d. The panel is offline for
a moment and comes back on the new version. No SSH, no rsync, no docker command typed by a human.
3. A self-hosted registry (Forgejo / Gitea — the EU-first path)
Everything above, with nothing leaving self-hosted infrastructure. The panel is identical — this is the same image, the same updater, the same button. Only the registry changes.
.forgejo/workflows/panel-image.yml is the same workflow pointed at a self-hosted instance. On that Forgejo:
-
Enable Actions on the repo and register a runner with docker available.
-
Generate a token with
write:package(Settings → Applications). -
Add these to the repo:
Secret REGISTRY_TOKENthat token Var REGISTRYgit.example.euVar IMAGE_NAMEspipov/spipcpVar REGISTRY_USERthe token's account Var DOCS_REPO_URLhttps://git.example.eu/spipov/spipcp-docs.git -
Point the box at it:
.env SPIPCP_IMAGE=git.example.eu/spipov/spipcp SPIPCP_TAG=latestIf the registry is private, run
docker login git.example.euonce on the box so the updater'sdocker compose pullis authorised.
Pushing a v1.5.0 tag lets every panel pointed at that registry pull it from its own UI. Same button,
same experience, and no dependency on GitHub.
Migrating from source-deploy to a registry
No reinstall is required, and nothing is lost. Set SPIPCP_IMAGE + SPIPCP_TAG=latest in
.env on the box, then docker compose up -d. The box stops building and starts pulling. The
database volume, the enrolled nodes, and the sites are untouched — as with any panel update, this is
an image swap, not a reinstall. pnpm deploy then becomes a dev-only convenience that can be ignored.
Announcing the release (closing the loop)
Publishing an image makes the new version available. It does not tell anybody it exists. That is what the changelog is for, and the two are joined by one field.
After CI publishes 1.5.0:
- Admin → Changelog → compose the release. Version
1.5.0, a title, the items. - Set its image tag to
1.5.0— the same string CI published. - Publish.
Now every panel in the world reads the public feed, sees that 1.5.0 is
published while it is running 1.4.0, and can tell its operator that an update exists — the operator then
presses Pull & restart. The version a human chose, the artifact it ships as, and the button that
installs it are finally the same fact.
That is the whole loop, and image_tag is the hinge it turns on.
Translating a release
A release is written by a person, so no catalog can hold it. With a LibreTranslate endpoint configured in Settings → Changelog, the compose dialog gains a Translate button: it translates the release into every language the panel offers, once, and stores the result. The changelog page, the "What's new" panel and the release email then each show a reader their own language, falling back to the authored text where there is no translation.
Three things it will not do, on purpose:
- It will not run by itself. Somebody presses the button. It is not folded into Publish, because an operator who has hand-corrected a French phrase must not have it silently overwritten by the next publish.
- It will not overwrite a human. A language that has been edited by hand is left exactly as it was, and re-translating skips it.
- It will not fail a publish. No engine, an engine that is down, an engine that mangles a phrase — the release ships in the language it was written in, which is what every release before this did.
Machine translations are marked as such, all the way out to the public feed, so a reader always knows who wrote what they are reading.
See also
- Versioning — why the image tag is not the version
- The changelog API — the feed a panel reads to know an update exists
- Installation — first install, and updating
Versioning
What SpipCP's version number means — an authored semver release, not a counter — how it differs from the deployed image tag, and why phase numbers are internal and never describe the product.
Changelog API
The public changelog feed — GET /api/v1/meta/changelog. The JSON shape, what is never in it, how it is ordered, the signed webhook that removes the need to poll, and how to mirror another panel's feed.