maintenant does not include built-in authentication — by design.
Like Prometheus, Dozzle, and most self-hosted monitoring tools, it delegates authentication to your existing reverse proxy and auth middleware. No need to manage yet another set of user accounts.
Internet → Reverse Proxy (Traefik / Caddy / nginx)
→ Auth Provider (Authelia / Authentik / OAuth2 Proxy)
→ maintenant
This page covers every aspect of securing a maintenant deployment: which routes to protect, which to leave open, and how to configure your reverse proxy accordingly.
Not all routes should sit behind authentication. Some must be publicly accessible for maintenant to function correctly.
These routes must bypass your authentication middleware:
| Route | Purpose | Rate Limited |
|---|---|---|
/ping/{uuid} |
Heartbeat ping — called by cron jobs, CI/CD pipelines, external services | Yes |
/ping/{uuid}/start |
Job start signal for duration tracking | Yes |
/ping/{uuid}/{exit_code} |
Job completion with exit code | Yes |
/status/api |
Status page JSON API | Yes |
/status/events |
SSE stream of status changes | Yes |
/status/feed.atom |
Atom feed of incidents | Yes |
/status/subscribe |
Email subscription to status updates | Yes |
/status/confirm |
Subscription confirmation (token-based) | Yes |
/status/unsubscribe |
Unsubscribe (token-based) | Yes |
Only registered when MAINTENANT_MCP=true. If MCP uses OAuth2 (recommended), these routes handle the OAuth flow and must bypass proxy-level auth — MCP has its own authentication:
| Route | Purpose |
|---|---|
/mcp |
MCP Streamable HTTP endpoint. Protected by OAuth2 bearer token when credentials are configured. |
/.well-known/oauth-authorization-server |
OAuth2 server metadata discovery (RFC 8414) |
/.well-known/oauth-protected-resource |
Protected resource metadata (RFC 9728) |
/oauth/authorize |
Authorization endpoint (PKCE S256 mandatory) |
/oauth/token |
Token exchange and refresh |
!!! warning "Open MCP"
When MAINTENANT_MCP=true is set without MAINTENANT_MCP_CLIENT_ID and MAINTENANT_MCP_CLIENT_SECRET, the /mcp endpoint is open — anyone can query your monitoring data. Either configure OAuth2 credentials or protect /mcp with your reverse proxy.
These routes provide full read/write access to your monitoring system. Always require authentication via your reverse proxy:
| Route | Purpose |
|---|---|
/api/v1/* |
Admin API — containers, endpoints, heartbeats, certificates, alerts, webhooks, status page management, update intelligence, resources |
/ |
Dashboard (Vue SPA) |
!!! danger "Do not expose /api/v1/ without authentication"
The admin API provides unrestricted access to all monitoring data and configuration: creating webhooks, managing heartbeats, viewing container logs, acknowledging alerts, and more. There is no authorization layer — any request that reaches the API is trusted.
services:
maintenant:
image: ghcr.io/kolapsis/maintenant:latest
read_only: true
security_opt:
- no-new-privileges:true
group_add:
- "${DOCKER_GID:-983}"
tmpfs:
- /tmp:noexec,nosuid,size=64m
labels:
traefik.enable: "true"
# Main router — requires authentication
traefik.http.routers.maintenant.rule: "Host(`now.example.com`)"
traefik.http.routers.maintenant.middlewares: "authelia@docker"
# Public routes — no auth
traefik.http.routers.maintenant-public.rule: >
Host(`now.example.com`) &&
(PathPrefix(`/ping/`) || PathPrefix(`/status/`))
traefik.http.routers.maintenant-public.priority: "100"
# MCP + OAuth routes — MCP handles its own auth
traefik.http.routers.maintenant-mcp.rule: >
Host(`now.example.com`) &&
(PathPrefix(`/mcp`) || PathPrefix(`/oauth/`) || PathPrefix(`/.well-known/`))
traefik.http.routers.maintenant-mcp.priority: "100"
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"
MAINTENANT_BASE_URL: "https://now.example.com"now.example.com {
# Public routes — no auth
@public path /ping/* /status/*
reverse_proxy @public maintenant:8080
# MCP routes — own OAuth2 auth
@mcp path /mcp /mcp/* /oauth/* /.well-known/*
reverse_proxy @mcp maintenant:8080
# Everything else — requires auth
forward_auth authelia:9091 {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy maintenant:8080
}
server {
listen 443 ssl;
server_name now.example.com;
# Public routes — no auth
location ~ ^/(ping|status)/ {
proxy_pass http://127.0.0.1:8080;
}
# MCP routes — own OAuth2 + SSE support
location ~ ^/(mcp|oauth|\.well-known)/ {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_read_timeout 86400s;
}
# Protected routes — requires auth
location / {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
proxy_pass http://127.0.0.1:8080;
}
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
}
}!!! tip "SSE and MCP"
The /mcp endpoint uses SSE for streaming. Disable response buffering and increase read timeouts in your proxy for this path. Caddy handles this natively — no special configuration needed.
Per-IP token bucket rate limiter applied to all public-facing routes:
| Setting | Value |
|---|---|
| Rate | 10 requests/second per IP |
| Burst | 20 requests |
| Applied to | /ping/, /status/*, /mcp |
| Not applied to | /api/v1/* (expected behind auth) |
| 429 response | {"error":{"code":"rate_limited","message":"Too many requests"}} with Retry-After: 1 |
IP detection priority: X-Real-IP header → first entry in X-Forwarded-For → RemoteAddr.
The /status/subscribe endpoint has an additional rate limit of 5 requests per IP per hour to prevent subscription abuse.
!!! tip "Trusted proxies"
Make sure your reverse proxy sets X-Real-IP or X-Forwarded-For correctly. Without it, all requests appear to come from the proxy's IP and share a single rate limit bucket.
POST and PUT request bodies are limited to 1 MB by default. Configurable via MAINTENANT_MAX_BODY_SIZE (in bytes).
A 10-second timeout is enforced on all non-streaming routes. Streaming paths are exempt:
/api/v1/containers/events(SSE)/api/v1/containers/{id}/logs/stream(SSE)/status/events(SSE)/mcp(MCP Streamable HTTP)
Controlled by MAINTENANT_CORS_ORIGINS:
| Value | Behavior |
|---|---|
| Unset (default) | No CORS headers — same-origin only |
* |
Access-Control-Allow-Origin: * |
| Comma-separated list | Allowlist with Vary: Origin |
The /status/api endpoint always returns Access-Control-Allow-Origin: * regardless of this setting, since the status page is designed to be embedded anywhere.
When MAINTENANT_MCP_CLIENT_ID and MAINTENANT_MCP_CLIENT_SECRET are both configured, the MCP endpoint is protected by a full OAuth 2.1 implementation:
- PKCE S256 mandatory on all authorization requests
- Opaque tokens — 32-byte random values stored as SHA-256 hashes (a database leak does not expose usable tokens)
- Client secret stored as SHA-256 hash with constant-time comparison
- Access tokens expire after 1 hour, refresh tokens after 30 days
- Refresh token rotation — each use invalidates the old token
- Replay detection — reusing a consumed refresh token revokes the entire token family
- Authorization codes expire in 10 minutes
- Automatic cleanup of expired tokens every 15 minutes
The stdio transport (--mcp-stdio) requires no authentication — it is a local, trusted channel only accessible to the process that spawned maintenant.
See MCP Server for full configuration and usage details.
The maintenant Docker image runs as nobody:nobody (uid/gid 65534) — never as root. Combined with the Compose security options, the container operates with minimal privileges:
services:
maintenant:
image: ghcr.io/kolapsis/maintenant:latest
read_only: true # immutable root filesystem
security_opt:
- no-new-privileges:true # prevent privilege escalation
group_add:
- "${DOCKER_GID:-983}" # Docker socket access (see below)
tmpfs:
- /tmp:noexec,nosuid,size=64m # scratch space for SQLite WAL
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc:ro
- maintenant-data:/data| Setting | Purpose |
|---|---|
USER 65534:65534 (Dockerfile) |
Process runs as nobody, not root |
read_only: true |
Root filesystem is immutable — no writes outside mounted volumes |
no-new-privileges |
Blocks setuid/setgid binaries and privilege escalation |
group_add |
Adds the host Docker group so nobody can read the socket |
tmpfs /tmp |
Writable scratch space with noexec and nosuid flags |
!!! tip "Finding your Docker GID"
Run stat -c '%g' /var/run/docker.sock on the host to get the Docker group ID.
Set it as DOCKER_GID in your .env file or pass it directly in group_add.
maintenant requires access to the Docker socket to discover and monitor containers. Mount it read-only:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc:roThe Docker socket grants root-equivalent access to the host. maintenant only reads container state, metadata, and logs — it never creates, modifies, or deletes containers. The read-only mount and non-root user are defense-in-depth measures — even if the process is compromised, it cannot escalate to root on the host.
For stricter isolation, consider using a Docker socket proxy like Tecnativa/docker-socket-proxy to restrict API access to only the endpoints maintenant needs.
maintenant binds to 127.0.0.1:8080 by default — localhost only. This prevents direct exposure to the network.
Inside a Docker container, use 0.0.0.0:8080 (the Dockerfile sets this automatically) but never publish the port directly to the host network. Let the reverse proxy handle external traffic.
!!! danger "Never expose maintenant directly to the internet" Without a reverse proxy providing authentication, anyone can access the admin API and read your container logs, metrics, and alerts.
SQLite in WAL mode. The database file contains all monitoring data, alert history, webhook configurations, and (if MCP OAuth is enabled) hashed tokens.
- Store on a local filesystem — NFS and network-mounted volumes cause locking issues with SQLite.
- Back up by copying the
.db,.db-wal, and.db-shmfiles while maintenant is stopped, or usesqlite3 .backupwhile running. - File permissions — ensure only the maintenant process can read/write the database file.
Ping URLs (/ping/{uuid}) use UUIDs as the sole access control. Anyone who knows the UUID can send pings.
- Treat heartbeat UUIDs as secrets.
- Do not commit them to public repositories.
- Do not log them in CI/CD output.
- Rotate a heartbeat's UUID by deleting and recreating it if you suspect a leak.
When creating webhooks, maintenant enforces HTTPS-only URLs. This prevents credentials in webhook payloads from being transmitted in cleartext.
A quick reference for securing your deployment:
- Container runs as non-root (
USER 65534:65534— default in the official image) -
read_only: true— immutable root filesystem -
no-new-privileges:true— blocks privilege escalation -
group_addset to host Docker GID (runstat -c '%g' /var/run/docker.sock) - Docker socket mounted read-only (
:ro) - Reverse proxy in front of maintenant with authentication enabled
-
/api/v1/*and/require authentication -
/ping/and/status/bypass authentication - If MCP is enabled: OAuth2 credentials configured (
MAINTENANT_MCP_CLIENT_ID+MAINTENANT_MCP_CLIENT_SECRET) - If MCP is enabled:
/mcp,/oauth/*,/.well-known/*bypass proxy auth (MCP handles its own) - HTTPS termination at the proxy level
-
MAINTENANT_BASE_URLset to your public HTTPS URL - Database file has restrictive permissions
- Heartbeat UUIDs not exposed in public repositories or logs
-
MAINTENANT_CORS_ORIGINSset appropriately (not*in production, unless intended)