# Deploying CluPilot on a fresh server

Developed locally, deployed by pulling. Two scripts do the work:

```bash
# 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:

```bash
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:

```bash
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:

```bash
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.
