# LicensePanel — Installation Guide

> Hosted online at **<https://license-install.mod-sol-sa.com/>** (this page); the
> installer scripts and version index live on the same host.

**Audience:** operators installing and running LicensePanel on their own server.
**Scope:** Linux (primary) and Windows Server; Docker-stack and standalone-binary
shapes; online, manual-verified, and air-gapped installs; first-boot setup;
upgrade; uninstall; the `server` CLI; and troubleshooting.

> Conventions in this guide:
> - `https://license-install.mod-sol-sa.com/…` is the canonical install URL your vendor gives
>   you (one URL for all customers; it is **not** versioned — you pin versions
>   with `--version`). The container image is published as **`ditssa/licensepanel`**
>   (Docker Hub, mirrored to GHCR).
> - macOS is not supported in R1 (planned via a Homebrew tap later).

---

## 1. What you get

LicensePanel installs as a small, self-managing control plane that runs your
licensed Docker-stack product(s). After install you complete a one-time
**Initial Configuration** in the browser, then manage everything from the panel
UI or the `server` CLI.

It ships in **two artifact shapes** (same brand-agnostic build for every
customer — your identity is applied at license-activation time):

| Shape | When to use | What runs |
|---|---|---|
| **Docker image** (primary) | Default. Docker is available (or installable) on the host. | A small compose stack: `panel` + `panel-postgres` + `traefik` + `dockerproxy` (+ optional bundled Keycloak). |
| **Self-contained binary** (secondary) | Locked-down hosts that can't run the panel in a container. | The `licensepanel` binary as a **systemd unit** (Linux) or **Windows Service**, plus its `wwwroot/` UI assets. |

Both shapes contain the same binary, including the embedded **`server` CLI**
(see §10) and the React SPA.

---

## 2. Requirements

| | Linux | Windows |
|---|---|---|
| OS | Ubuntu/Debian/RHEL/Fedora (systemd) | Windows Server 2019+ / Win 10/11 |
| Arch | `amd64` or `arm64` | `amd64` (arm64 later) |
| Privilege | root / `sudo` | Administrator (PowerShell 7+) |
| Docker | Engine **≥ 24.0** with Compose v2 (auto-installed if absent, unless `--no-docker`) | Docker Desktop or Engine (not auto-installed in R1) |
| Ports | **80**, **443** (Traefik) and **8443** (panel + bootstrap) must be free | same |
| Disk / RAM | ~2 GB free for the panel stack; product stacks need their own | same |
| Network | Outbound HTTPS to the release feed + your image registry (or use air-gap, §9) | same |

The Linux installer **refuses** to run inside a container (`/.dockerenv`
present) and refuses a state directory with unexpected permissions.

---

## 3. Quick start

### Linux (Docker shape — the common case)

```bash
# Self-signed bootstrap (reach the panel by IP, finish setup in the browser):
curl -sSL https://license-install.mod-sol-sa.com/install.sh | sudo bash

# Or DNS-ready with automatic Let's Encrypt TLS:
curl -sSL https://license-install.mod-sol-sa.com/install.sh | sudo bash -s -- \
  --panel-host panel.acme.example --acme-email ops@acme.example
```

On success the installer prints exactly one URL, e.g.:

```
LicensePanel installed.
Open in your browser:
  https://203.0.113.10:8443/

Bootstrap mode is active for the next 60 minutes.
If you can't reach the URL, run:
  ./server bootstrap status
```

No password or secret is ever printed. Continue at **§6 First boot**.

### Windows Server

```powershell
iwr -useb https://license-install.mod-sol-sa.com/install.ps1 | iex
# with flags:
& ([scriptblock]::Create((iwr -useb https://license-install.mod-sol-sa.com/install.ps1).Content)) `
  -PanelHost 'panel.acme.example' -AcmeEmail 'ops@acme.example'
```

---

## 4. Installer flags

Linux flags (PowerShell uses the `-PascalCase` equivalents):

| Flag | Default | Effect |
|---|---|---|
| `--panel-host <host>` | unset | Pre-seed the panel host; enables ACME (Let's Encrypt) at first boot. Unset ⇒ bootstrap self-signed cert. |
| `--acme-email <email>` | unset | **Required when `--panel-host` is set** — the Let's Encrypt account contact. |
| `--version <semver>` | `latest` | Pin the panel image/version (e.g. `1.0.0`). |
| `--no-docker` | off | Don't auto-install Docker (use the host's existing Docker/Podman, or the binary-service shape). |
| `--upgrade` | off | Re-run on an existing host, preserving all state (see §12). |
| `--no-start` | off | Write all state but don't start the stack (staged installs / config management). |
| `--from-tarball <dir>` | unset | **Air-gap** install from a local directory holding the tarball + `SHA256SUMS.asc` (see §9). |
| `--accept-tos` | off | Skip the interactive vendor terms-of-service prompt (required for unattended installs). |
| `--license <path>` / `--activation <token>` | unset | Reserved for the licensing flow; ignored in R1. |
| `--help` | — | Print help and exit 0. |

Equivalent env vars exist for automation: `LICENSEPANEL_PANEL_HOST`,
`LICENSEPANEL_ACME_EMAIL`, `LICENSEPANEL_VERSION`, `LICENSEPANEL_NO_DOCKER=1`,
`LICENSEPANEL_UPGRADE=1`, `LICENSEPANEL_NO_START=1`, `INSTALL_FROM_TARBALL=<dir>`.

---

## 5. What the installer does

1. **Preflight** — checks OS/arch, root, free ports (80/443/8443), and refuses
   to run inside a container.
2. **Docker** — verifies Engine ≥ 24 (installs via `get.docker.com` if missing
   and `--no-docker` is not set); ensures the daemon is running.
3. **State** — creates `/var/lib/licensepanel/` (mode `0700`) with
   `keys/`, `postgres-tls/`, `traefik/`, `data/`, and the `panel-stack/` compose
   files.
4. **Key custody** — generates a per-host master key (Linux: X.509 PKCS#12 +
   hardware-fingerprint binding; Windows: DPAPI machine scope) and a Postgres TLS
   cert + a self-signed bootstrap cert.
5. **Encrypted state DB** — initialises the SQLCipher-encrypted `state.db`.
6. **Start** — `docker compose up -d` brings up the panel stack (unless
   `--no-start`), waits for health, and prints the one bootstrap URL.
7. **Idempotency marker** — records version + timestamp so a re-run is detected.

State directories:

| | Linux | Windows |
|---|---|---|
| State root | `/var/lib/licensepanel/` | `C:\ProgramData\LicensePanel\` |
| Compose files | `/var/lib/licensepanel/panel-stack/` | n/a (service shape) |
| Binary (service shape) | `/opt/licensepanel/` | `C:\Program Files\LicensePanel\` |

---

## 6. First boot — Initial Configuration

Open the printed URL (`https://<host>:8443/`). The first browser hits a one-time
setup that is open for **60 minutes** ("bootstrap mode"). You provide:

- **Panel access** — host + TLS mode (`bootstrap_self_signed`, `acme`, or
  `custom_cert`); ACME email if using Let's Encrypt.
- **Defaults** — default locale (`en`/`ar`) and IANA timezone.
- **Operations** — audit retention days (≥ 30) and daily-prune toggle.
- **Authentication** — your identity provider: **Hosted Keycloak**,
  **Remote Keycloak**, or **Microsoft Entra ID**, plus the bootstrap admin email
  (and a generated admin password for Hosted Keycloak — save it when prompted).
- **SMTP** (optional).

Submitting commits everything atomically and **closes bootstrap mode**. You're
redirected to your IdP to sign in as the admin.

> **If the 60-minute window expires before you finish**, reopen it from the host:
> run `server bootstrap reactivate` (see §10) and reload the page.

> **Self-signed cert warning:** when you install without `--panel-host`, the
> bootstrap cert is self-signed — your browser will warn. Verify the fingerprint
> with `server bootstrap status` (it prints the cert SHA-256) before trusting it.

---

## 7. Choosing the distribution shape

- **Docker shape (default):** everything (panel, Postgres, Traefik, the Docker
  socket-proxy) runs as a compose stack. Traefik owns ports 80/443 and routes to
  the panel; the panel manages your product stacks through the hardened
  socket-proxy. This is the fully-wired, recommended path.
- **Binary / service shape (`--no-docker` hosts):** install from the tarball and
  register the `licensepanel` binary as a service. The panel still needs a
  container runtime (Docker or Podman) to manage *product* stacks; `--no-docker`
  only means *the panel itself* isn't containerised. See §8.

---

## 8. Manual (verified) install

For operators who want to inspect what they run before running it, or who deploy
the binary shape.

1. **Download** the artifacts — browse the version index at
   `https://license-install.mod-sol-sa.com/panel/` (or the GitHub Releases page);
   each version links to its assets on GitHub Releases:
   - `licensepanel-linux-amd64.tar.gz` (or `-arm64`, or `licensepanel-win-x64.zip`)
   - `SHA256SUMS` and `SHA256SUMS.asc`
2. **Verify the signature** against the vendor's published GPG key:
   ```bash
   gpg --verify SHA256SUMS.asc SHA256SUMS
   sha256sum -c SHA256SUMS --ignore-missing
   ```
3. **Extract** — the archive contains the `licensepanel` binary, its `wwwroot/`
   UI assets, and a `packaging/` directory (systemd unit + Windows service
   templates + `install-from-tarball.sh`).
4. **Install the binary shape (Linux):**
   ```bash
   sudo PREFIX=/opt/licensepanel ./licensepanel/packaging/install-from-tarball.sh
   ```
   This installs the binary, creates `/var/lib/licensepanel`, registers the
   systemd unit, and prints the bootstrap URL.
   **Windows:** run `packaging\windows\service-create.ps1` from an elevated shell.

---

## 9. Air-gapped install

The release feed is the only thing that needs internet; everything else works
offline.

1. **On a connected machine:** download the matching `…tar.gz`/`.zip` +
   `SHA256SUMS.asc`; verify the GPG signature.
2. **Transfer** the files to the air-gapped host (USB/SCP/mirror).
3. **On the air-gapped host**, point the installer at the local directory (no
   network is used):
   ```bash
   sudo bash install.sh --from-tarball /path/to/dir
   #   or:  sudo INSTALL_FROM_TARBALL=/path/to/dir bash install.sh
   ```
   The installer re-verifies the PGP signature + tarball SHA-256, and **refuses
   with zero residual files** on any mismatch (`responseCode=AIRGAP_SIGNATURE_INVALID`).

License activation in air-gap uses the offline `.lic` import flow (panel UI) with
`server fingerprint` (§10) — no network required.

---

## 10. The `server` CLI

The CLI ships inside the same binary (dual-mode: `licensepanel server <cmd>`).
Invoke it on the host:

```bash
# Docker shape:
docker compose -f /var/lib/licensepanel/panel-stack/docker-compose.yml \
  exec panel /app/licensepanel server <subcommand>

# Binary shape:
/opt/licensepanel/licensepanel server <subcommand>
```

(Older docs abbreviate these as `./server <subcommand>`.)

| Command | Purpose |
|---|---|
| `bootstrap status` | Show whether bootstrap mode is active + expiry + the self-signed cert fingerprint. |
| `bootstrap reactivate` | Reopen the 60-minute bootstrap window (only while no admin exists yet). |
| `healthcheck` | HTTP liveness probe of `/health` — exit 0 if healthy, non-zero otherwise (used by the container/service healthchecks). |
| `fingerprint` | Print the host hardware fingerprint for offline `.lic` activation. |
| `audit verify` | Verify the tamper-evident HMAC audit chain end-to-end. |
| `kek rotate` | Rotate the master key-encryption key (requires a restart). |
| `rollback` | Atomic binary + state rollback to the previous version. |

> Recovery/diagnostic commands above are wired today. The broader day-to-day
> management surface (projects, deploys, domains, licensing) is delivered through
> the panel UI; an expanded management CLI is on the roadmap.

---

## 11. Verifying the install

```bash
# Version (unauthenticated, static):
curl -fsk https://<host>:8443/api/version

# Deep health (200 healthy / 200 maintenance-degraded / 503 critical):
curl -sk -o /dev/null -w '%{http_code}\n' https://<host>:8443/health

# From the host, using the binary's own probe:
docker compose -f /var/lib/licensepanel/panel-stack/docker-compose.yml \
  exec panel /app/licensepanel server healthcheck
```

A panel that can't reach Postgres serves a **maintenance page** (it does not
crash-loop); fix the database and it recovers automatically.

---

## 12. Upgrading

Re-run the installer with `--upgrade` (state is preserved):

```bash
curl -sSL https://license-install.mod-sol-sa.com/install.sh | sudo bash -s -- --upgrade
# pin a target version:
curl -sSL https://license-install.mod-sol-sa.com/install.sh | sudo bash -s -- --upgrade --version 1.1.0
```

The upgrade pulls the new image, recreates the stack, and re-runs database
migrations at start. A re-run **without** `--upgrade` on an existing host refuses
(prints the installed version); a re-run at the same version is a no-op. The
state DB's SHA-256 is checked to be unchanged across an idempotent re-run.

For the binary shape, `server rollback` reverts to the previous binary + state if
an upgrade misbehaves.

---

## 13. Uninstalling

See [`implementation/uninstall.md`](./implementation/uninstall.md) for the full
procedure. In short: stop the stack/service, then remove the state root
(`/var/lib/licensepanel` or `C:\ProgramData\LicensePanel`) — **this deletes all
panel data, keys, and the encrypted state DB; back up first if needed.**

---

## 14. Unattended / config-management installs

```yaml
# Ansible
- name: Install LicensePanel
  shell: |
    curl -fsSL https://license-install.mod-sol-sa.com/install.sh | sudo bash -s -- \
      --panel-host {{ panel_host }} --acme-email {{ panel_acme_email }} \
      --accept-tos --no-start
  args:
    creates: /var/lib/licensepanel/.install-marker
- name: Start LicensePanel
  ansible.builtin.systemd: { name: licensepanel, state: started, enabled: true }
```

The `--no-start` + `creates:` pattern keeps the play idempotent.

---

## 15. Troubleshooting

### Installer exit codes

| Code | Meaning | Recovery |
|---|---|---|
| 0 | Success / idempotent re-run | — |
| 1 | Generic failure | Read the stderr message. |
| 2 | Preflight failed (RAM/disk/**port conflict**) | Free ports 80/443/8443 (`ss -tulnp`) or the conflicting service. |
| 3 | Docker install failed | Install Docker manually, re-run with `--no-docker`. |
| 4 | Air-gap signature/checksum invalid | Confirm you have the right vendor GPG key + intact files. |
| 5 | State dir has wrong permissions | Inspect `/var/lib/licensepanel` mode (expected `700/750`). |
| 6 | Hardware fingerprint failed | Ensure `/sys/class/dmi` is readable (bare-metal/VM, not a restricted container). |
| 7 | TLS cert generation failed | Check disk + OpenSSL availability; see install log. |
| 8 | `docker compose up` failed | `docker compose -f /var/lib/licensepanel/panel-stack/docker-compose.yml logs`. |

All install output is also written to `/var/log/licensepanel/install.log`.

### Common issues

- **Browser can't reach `https://<host>:8443/`** — confirm the firewall allows
  8443 and the stack is healthy (`server healthcheck`); the installer does **not**
  open firewall ports for you.
- **Bootstrap window expired** — `server bootstrap reactivate`, then reload.
- **Keycloak 401 during Initial Configuration** (bundled Hosted Keycloak) — the
  bundled admin credentials seed only on first volume creation; if stale, recreate
  the Keycloak data volume and restart the stack, then retry.
- **Panel shows a maintenance page** — Postgres is unreachable or a migration
  failed; check `panel-postgres` health and the panel logs. The panel recovers on
  its own once the DB is back.
- **"Existing install detected"** — re-run with `--upgrade`, or uninstall first.

### Where to look

- Panel/stack logs: `docker compose -f /var/lib/licensepanel/panel-stack/docker-compose.yml logs -f`
- Service-shape logs: `journalctl -u licensepanel -f` (Linux) / Event Viewer (Windows)
- Health: `https://<host>:8443/health` · Version: `/api/version`

---

## 16. Reference

- **Ports:** 80, 443 (Traefik), 8443 (panel + bootstrap).
- **State root:** `/var/lib/licensepanel/` (Linux), `C:\ProgramData\LicensePanel\` (Windows).
- **Install host:** `https://license-install.mod-sol-sa.com` (scripts + `/panel/` version index).
- **Image:** `ditssa/licensepanel:{version}` (Docker Hub) + `ghcr.io/dits-sa/licensepanel`.
- **Release artifacts** (GitHub Releases, linked from `/panel/`): `licensepanel-linux-amd64.tar.gz`, `licensepanel-linux-arm64.tar.gz`, `licensepanel-win-x64.zip`, `SHA256SUMS`, `SHA256SUMS.asc`; version index `latest.json` / `versions.json` at `/panel/`.
- **Related docs:** [`implementation/installation.md`](./implementation/installation.md) (design), [`implementation/uninstall.md`](./implementation/uninstall.md), [`implementation/updates.md`](./implementation/updates.md), [`implementation/reset-password.md`](./implementation/reset-password.md), [`adr/0008-distribution-and-installer.md`](./adr/0008-distribution-and-installer.md), [`adr/0044-single-binary-distribution-and-aot-burndown.md`](./adr/0044-single-binary-distribution-and-aot-burndown.md).

---

*Build & release internals (how these artifacts are produced) live in
[`superpowers/specs/2026-05-22-single-binary-build-and-release-design.md`](./superpowers/specs/2026-05-22-single-binary-build-and-release-design.md)
and `scripts/release.sh`.*
