SpipCP
Platform

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 fromWho runs the deployNeeds
1. From sourceBuilt on the boxAn operator, from a laptopSSH
2. Public registryGitHub Actions → GHCRThe operator, from the panelNothing
3. Self-hosted registryForgejo Actions → a self-hosted instanceThe operator, from the panelNothing

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:

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

Then, 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 separately

Why 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.0

That 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:

.env
SPIPCP_IMAGE=ghcr.io/spipov/spipcp
SPIPCP_TAG=latest

The 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:

  1. Enable Actions on the repo and register a runner with docker available.

  2. Generate a token with write:package (Settings → Applications).

  3. Add these to the repo:

    Secret REGISTRY_TOKENthat token
    Var REGISTRYgit.example.eu
    Var IMAGE_NAMEspipov/spipcp
    Var REGISTRY_USERthe token's account
    Var DOCS_REPO_URLhttps://git.example.eu/spipov/spipcp-docs.git
  4. Point the box at it:

    .env
    SPIPCP_IMAGE=git.example.eu/spipov/spipcp
    SPIPCP_TAG=latest

    If the registry is private, run docker login git.example.eu once on the box so the updater's docker compose pull is 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:

  1. Admin → Changelog → compose the release. Version 1.5.0, a title, the items.
  2. Set its image tag to 1.5.0 — the same string CI published.
  3. 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

On this page