5.7 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.
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.