Auto init via environment variables support, qu init for systemd
Container image / image (push) Successful in 1m38s

This commit is contained in:
2026-05-15 04:41:45 +00:00
parent 6953709574
commit e11b3f4547
9 changed files with 475 additions and 113 deletions
+45
View File
@@ -35,6 +35,8 @@ Override the socket path with `QUPTIME_SOCKET=/run/foo.sock`.
## Environment variables
### Paths
| Variable | Purpose |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `QUPTIME_DIR` | Data directory. Defaults to `/etc/quptime` (root) or `$XDG_CONFIG_HOME/quptime`. |
@@ -42,9 +44,52 @@ Override the socket path with `QUPTIME_SOCKET=/run/foo.sock`.
| `XDG_CONFIG_HOME` | Honored when running as non-root and `QUPTIME_DIR` is unset. |
| `XDG_RUNTIME_DIR` | Honored when running as non-root and `QUPTIME_SOCKET` is unset. |
### `node.yaml` field overrides
Every field in `node.yaml` can also be supplied via an environment
variable. This is the recommended way to drive Docker / Compose
deployments: drop the env vars into the compose file and the daemon
will bootstrap on first start without a separate `qu init` step.
| Variable | `node.yaml` field | Notes |
| ------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------- |
| `QUPTIME_NODE_ID` | `node_id` | Pin a specific UUID. Leave unset to let `qu init` / auto-init generate one. |
| `QUPTIME_BIND_ADDR` | `bind_addr` | Defaults to `0.0.0.0`. |
| `QUPTIME_BIND_PORT` | `bind_port` | Integer. Defaults to `9901`. |
| `QUPTIME_ADVERTISE` | `advertise` | `host:port` other peers use to reach this node. Required when bound to a wildcard or behind NAT. |
| `QUPTIME_CLUSTER_SECRET` | `cluster_secret` | Pre-shared join secret. Set the same value on every node. If unset on the very first node, one is generated. |
Precedence is **env > file > compiled default**. Non-empty env values
win over whatever is stored in `node.yaml` at load time, so changing a
variable in `docker-compose.yml` and restarting the container is
enough to roll out new bind/advertise values — no on-disk edit
required. Empty env values are ignored (they will not clear a
previously persisted field).
For `qu init` specifically, explicit command-line flags take
precedence over env values; env values fill in only the fields the
operator did not pass on the command line.
The daemon does not read any other environment variables. SMTP, Discord,
and HTTP probe targets are configured exclusively in `cluster.yaml`.
## Auto-init on `qu serve`
If `node.yaml` does not exist when `qu serve` starts, the daemon
bootstraps it in-place using the `QUPTIME_*` env vars above: a fresh
UUID is generated (or `QUPTIME_NODE_ID` is honored if set), an RSA
keypair and self-signed cert are written under `keys/`, and
`cluster.yaml` is seeded with this node as its sole peer. If no
`QUPTIME_CLUSTER_SECRET` was provided, a random one is generated and
printed to stderr — copy it to every follower node's
`QUPTIME_CLUSTER_SECRET` (or `--secret` flag) before they start.
This is what makes the docker-compose flow `docker compose up`-only
on a fresh volume. To opt out (e.g. so a misconfigured deployment
crashes loudly instead of silently generating a new identity), run
`qu init` against the volume yourself before letting `qu serve` ever
see it.
## `node.yaml` — local identity
Never replicated. One file per host. Generated by `qu init`.
+43 -14
View File
@@ -27,6 +27,14 @@ services:
image: git.cer.sh/axodouble/quptime:v0.1.0
container_name: quptime
restart: unless-stopped
environment:
# host:port other nodes use to reach this one. Must be reachable
# from every peer — the loopback inside the container is useless.
- QUPTIME_ADVERTISE=<host-ip>:9901
# Pre-shared join secret. Omit on the very first node and read
# the generated value out of `docker logs quptime`, then set
# this env var on every follower before bringing them up.
- QUPTIME_CLUSTER_SECRET=${QUPTIME_CLUSTER_SECRET:-}
ports:
- "9901:9901"
volumes:
@@ -41,17 +49,25 @@ volumes:
quptime-data:
```
You must **`qu init` before the daemon will start**. With this compose
file:
`qu serve` auto-initialises the data volume on first start using the
`QUPTIME_*` env vars (see [configuration.md](../configuration.md) for
the full list). One command brings everything up:
```sh
docker compose run --rm quptime init --advertise <host-ip>:9901
docker compose up -d
docker compose exec quptime qu status
```
`<host-ip>` must be reachable from every other node — the loopback
address inside the container is useless to peers.
On the very first node, capture the auto-generated cluster secret:
```sh
docker compose logs quptime | grep -A1 'cluster secret'
```
Copy that value into the `QUPTIME_CLUSTER_SECRET` env var of every
follower before starting them, otherwise their join RPCs will be
rejected. The full list of accepted env vars lives in
[configuration.md](../configuration.md#nodeyaml-field-overrides).
## Three-node compose on a single host
@@ -69,18 +85,27 @@ services:
alpha:
<<: *quptime
container_name: alpha
environment:
- QUPTIME_ADVERTISE=alpha:9901
# First node: leave secret unset and read it from `docker logs`.
ports: ["9901:9901"]
volumes: ["alpha-data:/etc/quptime"]
bravo:
<<: *quptime
container_name: bravo
environment:
- QUPTIME_ADVERTISE=bravo:9901
- QUPTIME_CLUSTER_SECRET=${SECRET}
ports: ["9902:9901"]
volumes: ["bravo-data:/etc/quptime"]
charlie:
<<: *quptime
container_name: charlie
environment:
- QUPTIME_ADVERTISE=charlie:9901
- QUPTIME_CLUSTER_SECRET=${SECRET}
ports: ["9903:9901"]
volumes: ["charlie-data:/etc/quptime"]
@@ -93,15 +118,12 @@ volumes:
Bootstrap:
```sh
# First node: prints the secret to stdout.
docker compose run --rm alpha init --advertise alpha:9901
# Capture the secret (or read it back from alpha-data).
SECRET=$(docker compose exec alpha cat /etc/quptime/node.yaml | grep cluster_secret | awk '{print $2}')
docker compose run --rm bravo init --advertise bravo:9901 --secret "$SECRET"
docker compose run --rm charlie init --advertise charlie:9901 --secret "$SECRET"
docker compose up -d
# 1. Start alpha first to mint the cluster secret.
docker compose up -d alpha
# 2. Read the secret off alpha's stdout.
export SECRET=$(docker compose logs alpha | awk '/cluster secret/{getline; print $1}')
# 3. Bring up the followers — they pick up the secret from $SECRET.
docker compose up -d bravo charlie
# Invite from alpha. The hostnames resolve over the compose network.
docker compose exec alpha qu node add bravo:9901
@@ -127,6 +149,9 @@ services:
image: git.cer.sh/axodouble/quptime:v0.1.0
container_name: quptime
restart: unless-stopped
environment:
- QUPTIME_ADVERTISE=${QUPTIME_ADVERTISE} # host:9901 reachable from peers
- QUPTIME_CLUSTER_SECRET=${QUPTIME_CLUSTER_SECRET}
ports:
- "9901:9901"
volumes:
@@ -135,6 +160,10 @@ services:
- NET_RAW
```
Put the per-host values (`QUPTIME_ADVERTISE`, `QUPTIME_CLUSTER_SECRET`)
in a sibling `.env` file or a config-management secret so the compose
file itself is identical across hosts.
Persistence is a bind-mount under `/srv/quptime/data` so backups and
upgrades hit a known path. See [operations.md](../operations.md) for
the backup recipe.
+29 -22
View File
@@ -53,12 +53,21 @@ services:
quptime:
image: git.cer.sh/axodouble/quptime:v0.1.0
container_name: quptime
environment:
# host:port other QUptime nodes use to reach this one. Should be
# this node's tailnet IP / MagicDNS name. Auto-init reads this on
# first start.
- QUPTIME_ADVERTISE=${QUPTIME_ADVERTISE}
# Shared cluster join secret. Omit on the very first node to have
# it generated and logged for you, then copy it into every
# follower's .env.
- QUPTIME_CLUSTER_SECRET=${QUPTIME_CLUSTER_SECRET:-}
volumes:
- quptime:/etc/quptime
network_mode: "service:tailscale"
depends_on: [tailscale]
cap_add: [NET_RAW]
# No restart directive yet — needs `qu init` first.
restart: unless-stopped
volumes:
tailscale:
@@ -67,43 +76,41 @@ volumes:
### One-time bootstrap
Each host runs the same script with different `HOST` and `TAILSCALE_AUTHKEY`:
Each host runs the same compose file with a per-host `.env`:
```sh
# .env
# .env (alpha — the first node)
HOST=alpha
TAILSCALE_AUTHKEY=tskey-auth-xxxxxxxx
QUPTIME_ADVERTISE=100.64.1.1:9901 # this node's tailnet IP
# QUPTIME_CLUSTER_SECRET left unset — will be generated on first boot.
```
Start Tailscale alone first so it gets an IP:
Start the stack on the first host. `qu serve` auto-initialises the
volume using the env vars above, so a single `docker compose up`
brings everything up:
```sh
docker compose up -d tailscale
sleep 5
TSIP=$(docker compose exec tailscale tailscale ip --4)
echo "this node's tailnet IP: $TSIP"
docker compose up -d
docker compose logs quptime | grep -A1 'cluster secret'
# Pipe the secret through your password manager.
```
On the **first** host, init without `--secret`:
On every **other** host, write the same `.env` plus the captured
secret:
```sh
docker compose run --rm quptime init --advertise "$TSIP:9901"
# Grab the printed secret; pipe through your password manager.
# .env (bravo, charlie, …)
HOST=bravo
TAILSCALE_AUTHKEY=tskey-auth-xxxxxxxx
QUPTIME_ADVERTISE=100.64.1.2:9901
QUPTIME_CLUSTER_SECRET=<paste from alpha>
```
On every **other** host, paste the secret:
Bring them up and invite them from the first node:
```sh
docker compose run --rm quptime init \
--advertise "$TSIP:9901" \
--secret "$CLUSTER_SECRET"
```
Then bring up `qu` on every node and invite from the first:
```sh
# Each host
docker compose up -d quptime
docker compose up -d
# From alpha
docker compose exec quptime qu node add 100.64.1.2:9901
+19 -4
View File
@@ -146,15 +146,26 @@ both call this out.
load node.yaml: open ...: no such file or directory
```
Run `qu init` before `qu serve`. The daemon does not auto-init —
silently generating identities and secrets would be a worse failure
mode than crashing.
`qu serve` normally auto-bootstraps a missing `node.yaml` using the
`QUPTIME_*` env vars (see
[configuration.md](configuration.md#auto-init-on-qu-serve)). If you
still see this error, the most likely causes are:
- The data directory is read-only or owned by a different user — the
bootstrap can't write `node.yaml`. Fix permissions on
`$QUPTIME_DIR`.
- Something else removed `node.yaml` mid-run (a config-management
tool, a misconfigured volume). Re-run `qu serve` and it will
rebuild from env, or run `qu init` manually with the flags you
want.
```
node.yaml has empty node_id — run `qu init` first
```
Same fix.
`node.yaml` exists but lacks a `node_id`. Either delete the file and
let auto-init regenerate it, or run `qu init` against a wiped data
dir.
```
listen tcp :9901: bind: address already in use
@@ -197,3 +208,7 @@ sudo systemctl start quptime
The data directory is the only state. Wipe it and you're back to a
fresh node.
Under Docker (or any env-driven deploy), the explicit `qu init` step
isn't needed — wiping the data volume and restarting the container is
enough; `qu serve` will re-bootstrap from the `QUPTIME_*` env vars.