CluPilotCloud/docs/deployment.md

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: /admin answers only on the hostnames in ADMIN_HOSTS and 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 with whsec_ 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-catalogue
    

    Run 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-run lists exactly what would be created.

  • A look at the catalogue itself. php artisan plans:check reports 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/wireguard in the wireguard volume — 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.