PostgreSQL storage¶
By default, maintenant keeps everything in a SQLite file next to the binary. That is the right choice while one instance watches one host: nothing to install, nothing to administer, and a backup is a file copy.
It becomes a ceiling once the instance watches a fleet. The server holds one class of data the agents cannot rebuild: their identity and their enrolment. If the machine carrying the file is lost, so is the link between the server and every monitored host — bringing up a replacement would mean re-enrolling the fleet by hand, host by host, while monitoring is blind.
Pointing the server at a PostgreSQL database you already operate removes that: the instance stops being attached to its machine.
What the product does, and what stays yours
The product provides one property: it keeps nothing essential on the machine that runs it. Redundancy, backups, restores and promotion of the database are yours, as is detecting a dead instance and restarting it elsewhere. maintenant does not detect failures, elect a leader, or move anything.
Agents are never affected. An agent stores its state in SQLite, always, and refuses a connection string outright.
Requirements¶
- PostgreSQL 14 or newer, reachable from the instance, with an empty database and a role that may create tables in it.
- An instance in server or embedded mode. In agent mode the setting is refused, with a message naming the mode.
New install¶
Point the instance at the database with MAINTENANT_DATABASE_URL. The
connection string is written once, as a YAML anchor, so the instance and the
copy command further down can never drift onto two different databases:
# compose.yml
x-database-url: &database-url
"postgres://maintenant:secret@db.internal:5432/maintenant?sslmode=require"
services:
maintenant:
image: ghcr.io/kolapsis/maintenant:latest
restart: unless-stopped
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "127.0.0.1:8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc:ro
- maintenant-data:/data
environment:
MAINTENANT_ADDR: "0.0.0.0:8080"
MAINTENANT_DATABASE_URL: *database-url
MAINTENANT_LICENSE_KEY: "…"
volumes:
maintenant-data:
The database is yours and stays out of this file: the guide assumes a server you already operate, not one the stack brings up.
The schema is created on first start, with no manual step. Check it:
curl -s localhost:8080/api/v1/health | jq .storage
# { "engine": "postgres", "connected": true, "peers": 0 }
Without MAINTENANT_DATABASE_URL, nothing changes: SQLite, exactly as before.
Keep the password out of the file you commit
The connection string carries a password, and compose.yml usually ends up
in a repository. Put it in a .env file next to it and reference it from
the anchor instead:
A Compose secret works too. The product itself never writes the string anywhere, so the file is the only place it can leak from.
Transport encryption¶
When the connection string carries no explicit sslmode and the host is not
local, the product adds sslmode=require: an external database is reached
over TLS by default. Any explicit value wins, disable included, so a database
on the same host or a private link can be relaxed deliberately. For a database
crossing a network you do not control, prefer sslmode=verify-full, which also
checks the server's certificate against its hostname.
The connection string is never written to the logs, the API, the interface or
the telemetry. Where a target has to be named, it appears redacted:
postgres://maintenant@db.internal:5432/maintenant.
What must follow the instance¶
Two states live in the data directory rather than in the database:
| State | File | If the directory does not follow |
|---|---|---|
| Signed licence cache | <dataDir>/.maintenant-license |
Re-verified online at startup. Offline: Community until the network returns. |
| Update window record | <dataDir>/.maintenant-update-window |
A fresh grace window opens, which plays in your favour. |
Anonymous telemetry keeps its own state under MAINTENANT_TELEMETRY_DATADIR
(/data/shm by default). Losing it only breaks the continuity of anonymous
counters; nothing about the fleet depends on it.
So keep the /data volume with the instance when it moves. It is the only case
where a move is not fully transparent, and it is settled by carrying the volume,
which any cluster manager can do.
Migrating an existing install¶
An install already running on the local file can move to PostgreSQL without touching a single monitored machine. One command carries what the fleet cannot rebuild; everything else is re-reported or recomputed.
Add the connection string and a one-shot copy-store service next to the
instance you already run:
# compose.yml
x-database-url: &database-url
"postgres://maintenant:secret@db.internal:5432/maintenant?sslmode=require"
services:
maintenant:
# … your existing service, unchanged, plus:
environment:
MAINTENANT_DATABASE_URL: *database-url
copy-store:
# The profile keeps it out of `up`; `run` enables it by itself.
profiles: ["migrate"]
image: ghcr.io/kolapsis/maintenant:latest
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes:
# The volume the instance writes to. The copy opens it read-only.
- maintenant-data:/data
# No MAINTENANT_DATABASE_URL here on purpose: the source is the local file,
# the target is the argument. Giving it both would blur which is which.
command:
- "--db"
- "/data/maintenant.db"
- "--copy-store-to"
- *database-url
Then, in order:
# 1. Stop the instance: the source must not move during the copy.
docker compose stop maintenant
# 2. Carry what does not rebuild itself.
docker compose run --rm copy-store
# 3. Restart on the database, which the service now points at.
docker compose up -d maintenant
The agents reconnect on their own, with the identity they already had.
docker compose run keeps a terminal attached, so the command says what it is
about to do and waits for you. For an unattended run, add --yes to the
command: list.
What travels¶
Agent identities and enrolment tokens, container rows, declared endpoint, heartbeat and certificate monitors, notification channels and their secrets, alert triggers, silences, escalation policies, webhook subscriptions, per-container thresholds, the whole status page (settings, components, assets, FAQ, footer, subscribers), published incidents, planned maintenance windows, and your operator decisions: update exclusions, version pins, acknowledged findings.
What does not, and what you will notice¶
Everything the fleet rebuilds by itself: agent inventories (re-sent within 30 s), check and resource history, state transitions, active alerts and their deliveries, CVE and image-update intelligence, ephemeral OAuth tokens.
Two effects are worth knowing before you start, and the command says them before writing anything:
- An alert acknowledged before the copy comes back unacknowledged if it is still active. The acknowledgement lives on the alert row, which does not travel. One click to redo, but it should not surprise you.
- Curves start from zero: resource, uptime and check history. Aggregates rebuild at their usual intervals.
Guarantees¶
| Situation | What happens |
|---|---|
| The target is not empty | Refused before writing anything. Merging would need conflict rules nothing can settle correctly. |
| The copy fails half-way | The whole transaction rolls back, schema included. The target is empty again and you can retry. |
| Whatever happens | The source is opened read-only and never written to. Your original install stays usable. |
| The copy succeeds | Row counts are compared table by table, source against target, and any difference is an error. |
Exit codes: 0 copied and verified, 1 refused (non-empty target, unreadable
or out-of-date source, unreachable target, or you declined), 2 failed
mid-copy and rolled back.
The copy does not switch your configuration: you set
MAINTENANT_DATABASE_URL and restart yourself, deliberately. It does not run
in the other direction either — going back means removing the variable, which
returns to the local file as you left it.
Latency¶
Every write now costs a round trip to the database, where a local file cost none. Put the database in the same region — ideally the same network — as the instance. The measure that matters is the one you feel: the delay between an event reported by an agent and its appearance on screen should stay in the same class as before. A database several tens of milliseconds away is fine; one on another continent is not.
The probe must not kill the pod¶
/api/v1/health answers 200 even when the database is momentarily
unreachable — the outage is reported in storage.connected, not in the HTTP
status. This is deliberate: the endpoint is the target of the Kubernetes
liveness and startup probes, and a probe failing on a ten-second blip would
restart the instance exactly when the database needs to be left alone.
Do not replace it with a check that fails on that case.
Two instances on one database¶
If a second instance beats on the same database, peers is non-zero and a
warning is logged naming their count, hosts and versions. The product does not
arbitrate: exclusion belongs to your cluster manager. Saying it is the only
action it takes.
Testing against PostgreSQL¶
Two levels, both able to run on either engine.
The Go suites. They run on SQLite by default, and on PostgreSQL when a test
server is configured. make test-pg starts a disposable PostgreSQL 14, points
MAINTENANT_TEST_DATABASE_URL at it and runs the store suite:
Without that variable, the PostgreSQL cases are skipped, never failed, so
go test ./... still works without Docker.
That skip is convenient locally and dangerous in CI: a typo in the variable
name, or a database service that never came up, would skip every PostgreSQL
case and report success. MAINTENANT_REQUIRE_POSTGRES=1 turns each of those
skips into a failure, and CI sets it on every step that is supposed to exercise
PostgreSQL. Use it whenever a green run is meant to prove PostgreSQL coverage:
The whole product. A test stack builds the image and runs it with the services a real install talks to (SMTP catcher, webhook receiver, a container to watch), then drives it through its HTTP surface:
make e2e-sqlite # the stack on the default local file
make e2e-postgres # the same stack, on PostgreSQL 14
make e2e-both # both in turn, on clean state — this is the one that matters
e2e-both is the check worth running before shipping anything that touches
storage: identical results on both engines is the claim, and a difference shows
up as a failed line rather than as a surprise in production.
To poke at a running stack instead: make e2e-up-postgres, then
http://127.0.0.1:18090 (mail on :18091, webhooks on :18092). make
e2e-logs follows the instance, make e2e-down removes everything including
the volumes. Ports are overridable (E2E_HTTP_PORT, E2E_DB_PORT, …) if
something already listens there.
The migration, end to end. make e2e-migrate takes a running SQLite stack,
stops it, copies it into the PostgreSQL one the way an operator would, restarts
on the database and re-runs the checks — so the path from an existing install
to an external database is exercised, not just described. It runs the very
copy-store service shown above, on the released image and entrypoint, so what
CI proves is the command you type.
Going back¶
Remove MAINTENANT_DATABASE_URL from the service and docker compose up -d
maintenant: the instance finds its SQLite database in the state it left it. Whatever was written to PostgreSQL in the
meantime is not there, and the migration command does not run in that
direction.