8.1 KiB
Deploying CluPilot on a fresh server
Developed locally, deployed by pulling. Two scripts do the work:
# once, on a fresh Debian/Ubuntu server — as root, because of apt
bash deploy/install.sh
# afterwards, as the service account — never as root
sudo -u clupilot bash -c 'cd /opt/clupilot && bash deploy/update.sh'
The installer needs root only to install packages and open the firewall. It
creates a clupilot account that owns the checkout and runs Docker; the
application never runs as root, and update.sh refuses to start if you try.
That account has no password login — membership in the docker group is
root-equivalent on the host, which is inherent to Docker and the reason it is
reachable only by sudo -u or an SSH key.
What the installer does
Docker, git and wireguard-tools; clone into /opt/clupilot; generate every
secret (APP_KEY, VPN_CONFIG_KEY, database password, Reverb keys); start the
stack; migrate; create the WireGuard hub and print its public key; create your
Owner account; close the firewall down to 22, 80, 443 and 51820/udp.
It is safe to re-run: an existing .env is kept and the hub key is never
regenerated — doing so would silently disconnect every onboarded host.
Unattended, from a bare server
The only thing to install by hand is git:
apt-get update && apt-get install -y git
git clone https://git.bave.dev/boban/CluPilotCloud.git /tmp/clupilot
bash /tmp/clupilot/deploy/install.sh --env-file /root/clupilot.env
deploy/clupilot.env.example is the template. Put the real file on the server
as /root/clupilot.env with mode 600 before starting — the installer warns
if it is readable by anyone else. Keep the master copy in a password manager,
never in the repository (clupilot.env is gitignored for that reason).
After a successful run the working values live in /opt/clupilot/.env, so the
bootstrap copy can go: shred -u /root/clupilot.env.
The file also carries the operational secrets — Hetzner DNS, Stripe, SMTP, the Proxmox SSH key path. Those are optional: the install succeeds without them and the corresponding features simply stay dark until they are filled in.
Order of the update
Migrations run before the new containers take traffic: code that expects a
column the database does not have yet answers with 500s. The image is rebuilt
only when docker/, composer or npm manifests actually changed. Queue workers
are restarted at the end — they are long-running PHP processes and keep the old
classes in memory otherwise. That one bit us during development: a fixed job
kept failing until the worker was restarted.
Versions and releases
VERSION at the repository root is the release number, and it is the only place
it is written down. A file rather than git describe, because a source archive,
a shallow CI checkout and a container built without .git all have no tags to
describe.
Cutting a release is editing that file and merging it to main. CI creates the
annotated tag v<VERSION> once the suite is green, and never moves it
afterwards — servers are pinned to these, and a tag that shifts underneath them
means two machines claiming to be the same version. The older tested-… tags
are a separate thing: a CI marker on every green commit, not a release.
Two ways to run a server, and the difference matters.
| pinned to a release | following a branch | |
|---|---|---|
| install | RELEASE=v1.0.0 bash install.sh |
BRANCH=main bash install.sh |
| update | RELEASE=v1.1.0 bash deploy/update.sh |
bash deploy/update.sh |
| moves when | you say so | someone pushes |
Production belongs on a release. main is the edge: CI tags a commit after it
is pushed, so origin/main is briefly — and after a failed run permanently —
code nobody has verified.
The mode is remembered. Re-running the updater on a pinned server does not
quietly drag it onto main; moving it takes an explicit RELEASE=.
Going backwards is refused. The schema has already migrated forward, and old code against a new schema is exactly what maintenance mode exists to prevent — with the extra cruelty that the migrations needed to roll back are not in the older checkout at all. A real downgrade means restoring the database snapshot taken before the upgrade, deliberately, and then installing the older release. Detection is by commit ancestry rather than by comparing version strings: a tag can be cut from anywhere, and only ancestry says whether history is going back.
What the console shows comes from storage/app/deployment.json, written
atomically after every deployment step has succeeded — not from live git. Git
tells you what the files say, not whether the deployment came up. So during a
failed update the console still reports the version that is actually serving.
A deployment pinned to the tag reads 1.0.0 (abc1234). Anything else reads
1.0.0-dev (abc1234) · main, because every commit after the tag still carries
VERSION=1.0.0 and is not that release. Reporting it as one is how a bug gets
filed against the wrong code.
What the installer deliberately leaves to you
-
DNS and TLS. Put a reverse proxy in front (Zoraxy, Caddy, nginx). The console hostname must not be publicly resolvable:
/adminanswers only on the hostnames inADMIN_HOSTSand 404s everywhere else, but keeping it out of public DNS is the layer above that. -
STRIPE_WEBHOOK_SECRET. Stripe dashboard → Developers → Webhooks →https://<app-domain>/webhooks/stripe. The secret starts withwhsec_and differs between test and live mode. Subscribe all six events the application handles — the first two open a contract, the rest are the billing cycle, which is Stripe's:checkout.session.completed checkout.session.async_payment_succeeded invoice.paid invoice.payment_failed customer.subscription.updated customer.subscription.deleted -
The Stripe catalogue. Nothing can be sold until Stripe knows what it is billing for:
docker compose exec app php artisan stripe:sync-catalogue --dry-run # look first docker compose exec app php artisan stripe:sync-catalogueRun it again after publishing any new plan version. It is idempotent, and it has to be: a Stripe Price cannot be edited or deleted, so a duplicate is permanent.
--dry-runlists exactly what would be created. -
A look at the catalogue itself.
php artisan plans:checkreports overlapping availability windows, plans on sale with no live version, and missing prices. Worth running after any plan change — all of it is computed when read, so a mistake announces itself at a customer's checkout otherwise. -
Backups. At minimum the MariaDB volume and
/etc/wireguardin thewireguardvolume — losing the hub key means re-peering every host.
Hiding the site while you build
Settings → Website visibility. Outsiders then get a placeholder (503,
noindex), while anyone on the management VPN and any signed-in operator keeps
seeing the real thing. The console stays reachable either way, so the switch can
always be flipped back.
CI (Gitea Actions)
.gitea/workflows/tests.yml runs the Pest suite and an asset build on every
push, and tags a green main as tested-…. That tag is what the console will
offer as an available update — a red run produces no tag and therefore no
update.
Actions has to be switched on twice: globally in Gitea's app.ini
([actions] ENABLED = true, then restart) and per repository under
Settings → Advanced. Verify with:
curl -s -H "Authorization: token <token>" \
https://git.bave.dev/api/v1/repos/boban/CluPilotCloud | grep -o '"has_actions":[a-z]*'
The runner is an opt-in compose service. Fetch a registration token from
Settings → Actions → Runners → "Create new runner" (Gitea 1.20 has no API for
it), put it in .env as GITEA_RUNNER_TOKEN, then:
docker compose --profile ci up -d runner
Keep GITEA_RUNNER_VERSION in step with the server: the registration protocol
moves with Gitea, and a runner newer than the server refuses to register.
The uses: steps are fetched from github.com, so the runner needs outbound
internet — the code itself never leaves your Gitea.