Troubleshooting¶
Permission denied on /var/run/docker.sock¶
Symptom: maintenant starts but shows no containers, and the logs contain:
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
Why it happens: maintenant runs as nobody (uid 65534) by design — the Docker image never grants root access. The Docker socket on the host is owned by root:docker. The process needs membership in the socket's group, otherwise the kernel rejects the open call regardless of the read-only mount.
Normally this is automatic. The entrypoint detects the group of the mounted /var/run/docker.sock and grants the unprivileged user access to it — on plain Compose and Docker Swarm. Just mount the socket:
services:
maintenant:
image: ghcr.io/kolapsis/maintenant:latest
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
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_DB: "/data/maintenant.db"
restart: unless-stopped
Don't rely on group_add for Swarm
docker stack deploy silently ignores group_add (it is not part of the Swarm service
spec), which is why socket access used to fail on Swarm. Auto-detection makes group_add
unnecessary there — see the Swarm guide.
If it still fails (non-standard socket path, socket proxy, or you want to pin the GID), set DOCKER_GID explicitly. Find the socket's group on the host:
Create a .env file next to your docker-compose.yml:
and pass it to the container (either as an environment variable, which the entrypoint reads, or via group_add on plain Compose):
Synology DSM (Container Manager) and other root-owned sockets¶
Synology DSM ships no docker group: /var/run/docker.sock is root:root, so the GID the
entrypoint detects is 0. Auto-detection deliberately refuses to hand the unprivileged runtime
root's group, so container discovery stays empty and the entrypoint logs:
maintenant: /var/run/docker.sock belongs to group root (gid 0) and the runtime drops to an
unprivileged user, so the Docker API will be unreachable.
Two ways out:
- Grant gid 0 explicitly — set
DOCKER_GID: "0"on the service. Only an explicit value unlocks the root group; nothing is granted implicitly. - Front the socket with a proxy (recommended, and the hardened setup on any host) — see
Security → Docker socket proxy. The proxy holds
the socket and rejects every write at the HTTP layer; maintenant reaches it over
DOCKER_HOST: "tcp://socketproxy:2375", so no socket mount and no GID are involved.
SELinux (Fedora / RHEL / Rocky / CentOS)¶
If the GID fix above does not resolve the error, SELinux may be blocking the socket access. Check for recent denials:
If you see a denial for docker.sock, add the :z relabel flag to the socket mount so SELinux applies the correct context:
The :z flag relabels the bind mount with a shared label (svirt_sandbox_file_t), which grants container processes access while keeping SELinux enforcing.
Docker rootless¶
With rootless Docker, the socket is not at /var/run/docker.sock but at $XDG_RUNTIME_DIR/docker.sock, typically /run/user/<uid>/docker.sock. Adjust the bind mount accordingly:
# Find the socket path on the host
echo $XDG_RUNTIME_DIR/docker.sock
# e.g. /run/user/1000/docker.sock
Replace 1000 with the UID of the user running the rootless daemon (id -u on the host). The group_add configuration is not required in rootless mode — the socket is owned by the user, not by a docker group.
An HTTPS host shows as down (or degraded) with "unknown authority"¶
The host is answering; maintenant just refuses to trust the certificate it presents. Since it is reachable, it is reported as degraded rather than down.
If the certificate comes from your own PKI, trust the root instead of disabling verification:
environment:
MAINTENANT_CA_CERT: /etc/maintenant/ca.pem
volumes:
- ./ca.pem:/etc/maintenant/ca.pem:ro
Make sure the mounted file is readable by uid 65534 — the container runs unprivileged, and a root-owned 0600 file will not be read. If the endpoint is attached to an agent, set this on the agent: it is the one performing the probe.
Do not reach for SSL_CERT_FILE. Go treats it as a replacement for the whole system bundle rather than an addition, so setting it drops every public CA, and an unreadable file yields an empty trust store with no error reported.
As a last resort you can turn verification off for a single container endpoint with the label maintenant.endpoint.http.tls-verify=false, but that also stops expiry and hostname checks for it.
An agent host flaps: connected, then disconnected, every 60 seconds¶
Symptom: the host on the Agents page alternates between connected and disconnected
at a regular interval, and its containers show up or vanish depending on when you look. The
agent logs show a reconnect loop with no mention of a proxy — it reads like an unstable network
link.
Why it happens: the agent stream is a single gRPC request whose body never ends. A reverse
proxy that caps the duration of a request cuts it at that limit no matter how much traffic
flows on it. On Traefik v3 that cap is respondingTimeouts.readTimeout, 60 s by default on
every entrypoint.
Fix: disable the read timeout on the entrypoint that carries gRPC. It is a per-entrypoint setting, so a dedicated entrypoint is needed to avoid dropping the protection for all HTTPS traffic:
- --entrypoints.grpc.address=:8443
- --entrypoints.grpc.transport.respondingTimeouts.readTimeout=0
- --entrypoints.grpc.transport.respondingTimeouts.idleTimeout=0
Agents then need the port in their URL (--server=grpcs://agents.example.com:8443). The
equivalent settings are grpc_read_timeout on nginx and timeout tunnel on HAProxy. Full
configuration, including the ACME side effect of a non-443 entrypoint, is in the
Agent Setup guide.
The database keeps growing, or the -wal file is huge¶
Up to and including 1.3.7, the retention cleanup deleted at most 1000 rows per hour, whatever
the number of monitored containers. Since each container produces around 360 raw samples per
hour, the purge fell behind past roughly three containers and resource_snapshots grew without
bound. Upgrading fixes the throughput: the first pass runs at startup and drains the whole
backlog.
Check where you stand:
sqlite3 /data/maintenant.db "
SELECT COUNT(*) AS rows, datetime(MIN(timestamp),'unixepoch') AS oldest FROM resource_snapshots;
PRAGMA auto_vacuum;
PRAGMA freelist_count;
"
oldest should stay inside the retention window (7 days by default). The retention cleanup:
deleted resource snapshots log line should no longer report a count stuck at exactly the batch
size — that was the signature of the purge never catching up.
Reclaiming disk space already used¶
PRAGMA auto_vacuum tells you whether freed pages return to the filesystem:
2(incremental) — nothing to do. Retention hands freed pages back automatically andfreelist_countshrinks pass after pass.0(none) — the database was created before auto-vacuum was enabled and only a fullVACUUMcan convert it. maintenant logs a warning at startup in this case. The database stops growing regardless, since SQLite reuses freed pages, but the file stays at its high-water mark.
A full VACUUM is a manual, offline operation. It takes an exclusive lock for its whole duration
(minutes on a multi-gigabyte database, much longer on an SD card or NAS) and rewrites the file, so
you need free disk space equal to the current database size on top of it:
docker compose stop maintenant
sqlite3 /path/to/maintenant.db "PRAGMA auto_vacuum=INCREMENTAL; VACUUM;"
docker compose start maintenant
Take a backup first. Running out of disk space mid-VACUUM leaves a journal file behind.
The -wal file¶
Newer versions set, on every connection, a journal_size_limit of 64 MiB, so the WAL is truncated back after each
checkpoint. Up to 1.3.7 it was unbounded and only shrank when the process restarted.
PostgreSQL storage refuses to start, or goes quiet¶
These apply when MAINTENANT_DATABASE_URL is set. Without it, the instance
uses its local SQLite file and none of this concerns you.
The instance exits at startup¶
The message names the cause and what to correct. There is deliberately no fallback to the local file: starting on an empty local database while the external one is misconfigured would look like it worked, and silently strand the fleet.
| Message | What happened | What to check |
|---|---|---|
| the database connection string cannot be read | The value is not a PostgreSQL URL | Expected postgres://user:password@host:5432/database[?sslmode=require] |
| the database does not answer | Nothing listens, or the route is blocked | Host, port, network route, firewall, and that the server is up |
| the database refused the credentials | It answered and said no | User, password, and that this role may connect to this database |
| the database version is not supported | Older than PostgreSQL 14 | Upgrade the server; 14 is the oldest release still supported upstream |
| the database schema was written by a newer release | A newer binary already migrated it | Run that version, or upgrade this one — it will not write into a schema it does not understand |
| MAINTENANT_DATABASE_URL is not accepted in agent mode | An agent was handed a connection string | Drop the setting: an agent always stores its state locally |
None of these messages contain the password. Where the target is named it
appears as postgres://user@host:5432/database.
The schema check also applies to SQLite
A binary older than the schema it opens refuses to start on the local file too. This is the one behaviour change for an existing local install, and it only triggers on a downgrade: it stops the older binary from writing into a schema it does not know, which used to corrupt data silently.
The database becomes unreachable while running¶
The instance stays up. It does not restart, does not fall back, and recovers on its own when the database answers again — the connection pool renews its connections. You will see:
- a STORAGE OFFLINE banner in the interface, and screens keeping what they already knew rather than emptying out;
503 STORAGE_UNAVAILABLEon API reads that need the database;storage.connected: falsein/api/v1/health, which still answers200.
That last point is deliberate and important: /api/v1/health is the target of
the Kubernetes liveness and startup probes. Do not make the probe fail on a
database outage — it would restart the instance exactly when the database
needs to be left alone. Read storage.connected instead.
Two instances warn about each other¶
If the log says another instance is working on the same database, and peers
is non-zero in /api/v1/health, two instances are running against it. The
product does not arbitrate — exclusion is your cluster manager's job. Data is
not corrupted, but purges and alert evaluation run twice. Stop one of them.