Skip to content

DigitalOcean Deployment Guide

How to run maintenant on DigitalOcean: a Droplet provisioned with cloud-init, a Cloud Firewall, a Volume for the database, agents over the VPC, and DOKS for the Kubernetes side.


Why it fits a small Droplet

maintenant is a single Go binary with the frontend embedded and SQLite as its default store. A freshly started instance fits in a few tens of MB of RAM; plan on about 100 MB after several days on a busy host. It needs no database server, no Prometheus, no Redis. A Basic Droplet running your workload has room for it.

Slug vCPU RAM Typical fit
s-1vcpu-2gb 1 2 GiB One host, a handful of containers and endpoint checks.
s-2vcpu-4gb 2 4 GiB Central server for a fleet of agents, or a busy update/CVE scan schedule.
Larger Only once you move the store to PostgreSQL and monitor hundreds of containers.

Size slugs are moving

DigitalOcean is rolling out a v5 generation (s5-, g5-) alongside the classic s- Basic slugs. Rather than trust a slug from a guide, list what your account can actually create:

doctl compute size list

Step 1: Create the Droplet

The repository ships a ready-to-use cloud-config at deploy/cloud-init/maintenant.yaml. It installs Docker from the official repository, writes /opt/maintenant/compose.yml, and starts the stack on first boot.

doctl compute droplet create maintenant \
  --image ubuntu-24-04-x64 \
  --size s-2vcpu-4gb \
  --region fra1 \
  --ssh-keys <your-key-fingerprint> \
  --tag-names maintenant \
  --user-data-file deploy/cloud-init/maintenant.yaml \
  --wait

Then reach the dashboard, which the cloud-config binds to loopback:

ssh -L 8080:127.0.0.1:8080 root@$(doctl compute droplet get maintenant --format PublicIPv4 --no-header)

Open http://localhost:8080. Every container on the host is already discovered.

The Docker socket is root on this Droplet

The cloud-config mounts /var/run/docker.sock. :ro only protects the socket file: the Docker API behind it still accepts writes, so whoever controls maintenant can start containers on this Droplet. Before you open the dashboard to anyone else, put a read-only docker-socket-proxy in front of the socket. See Security: Docker socket proxy, and enable IMAGES on the proxy as well if you use update checks.

Keeping the database on a Volume?

Create the Volume first and add --volumes <volume-id> to the command above: DigitalOcean only mounts a Volume that is attached while the Droplet is created. See Step 3.

User data is set once, at creation

A Droplet's user data cannot be changed afterwards, and it is capped at 64 KiB. Get the file right before you create the Droplet; to change it later, edit /opt/maintenant/compose.yml on the host directly.

The --tag-names maintenant is not decoration: firewall rules below target that tag, so a new Droplet joins the right rules by carrying the tag.


Step 2: Cloud Firewall

droplet create has no --firewall flag, so the firewall is created separately and attached by tag.

doctl compute firewall create \
  --name maintenant \
  --inbound-rules "protocol:tcp,ports:22,address:$(curl -s https://ipv4.icanhazip.com)/32 protocol:tcp,ports:80,address:0.0.0.0/0 protocol:tcp,ports:443,address:0.0.0.0/0" \
  --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0 protocol:udp,ports:53,address:0.0.0.0/0" \
  --tag-names maintenant

Note what is not in that list: port 8080. Publish the dashboard through a reverse proxy with authentication, never as a raw port on the public IPv4.

maintenant will flag its own exposure

If you change the Compose file to publish "8080:8080", maintenant's own network security scanner reports a critical Port exposed on all interfaces finding for its own container. That is the intended behaviour, not a bug. See the note in Installation → Docker Compose and Configuration → Choosing a Bind Address.

To add a rule later, use add-rules, never update:

doctl compute firewall add-rules <firewall-id> \
  --inbound-rules "protocol:tcp,ports:8443,tag:maintenant"

firewall update resets every attribute you do not pass, which silently drops the rules you already have.

Behind a reverse proxy

Authentication is the proxy's job: maintenant has none. Security has working Traefik, Caddy and nginx setups. Two settings in /opt/maintenant/compose.yml make maintenant behave correctly behind it:

    environment:
      MAINTENANT_BASE_URL: "https://maintenant.example.com"
      MAINTENANT_TRUSTED_PROXIES: "172.18.0.1"
  • MAINTENANT_BASE_URL is the public address. It defaults to http://0.0.0.0:8080 here, which is what heartbeat ping URLs, status page subscriber links and the MCP OAuth issuer would otherwise carry.
  • MAINTENANT_TRUSTED_PROXIES lists the addresses the proxy connects from. Empty, no forwarded header is read and every visitor is counted as the proxy, so they share one rate limit. A proxy on this Droplet reaches the port published on 127.0.0.1 through the Docker network's gateway, so list that address (docker network inspect maintenant_default, field Gateway), not 127.0.0.1. See Running behind a proxy.

Step 3: Put the database on a Volume

The Droplet's disk goes away with the Droplet. A Volume survives a rebuild, resizes, and can be snapshotted on its own. Volumes start at 1 GiB and cost $0.10 per GiB per month.

Attach it at creation time. DigitalOcean formats and mounts a Volume automatically only when it is attached to a Droplet being created; a Volume attached to an existing Droplet has to be partitioned and mounted by hand. So create the Volume before the Droplet, and run this in place of the command from Step 1:

doctl compute volume create maintenant-data \
  --region fra1 --size 20GiB --fs-type ext4

doctl compute droplet create maintenant \
  --image ubuntu-24-04-x64 --size s-2vcpu-4gb --region fra1 \
  --ssh-keys <your-key-fingerprint> --tag-names maintenant \
  --volumes <volume-id> \
  --user-data-file deploy/cloud-init/maintenant.yaml --wait

If you already created the Droplet in Step 1 without the Volume, delete that empty Droplet first (doctl compute droplet delete maintenant): running the command again would leave two Droplets named maintenant.

The mount path is /mnt/<volume-name>, with hyphens turned into underscores to match systemd mount unit naming. maintenant-data therefore lands on /mnt/maintenant_data. Point the Compose volume at it:

# /opt/maintenant/compose.yml
    volumes:
      - /mnt/maintenant_data/maintenant:/data
    environment:
      MAINTENANT_DB: "/data/maintenant.db"

You do not need to create or chown the maintenant directory: Docker creates it as root if it is missing, and the entrypoint hands it to uid 65534 at start.

The first boot already started maintenant on the named volume maintenant_maintenant-data, and the database created there does not follow the new mount: the next start opens an empty database at the new path. That is fine on a Droplet you have just created. To keep what was collected, copy it across while the stack is stopped (cp -a keeps the ownership):

docker compose -f /opt/maintenant/compose.yml down
mkdir -p /mnt/maintenant_data/maintenant
cp -a /var/lib/docker/volumes/maintenant_maintenant-data/_data/. /mnt/maintenant_data/maintenant/
# edit compose.yml as above, then
docker compose -f /opt/maintenant/compose.yml up -d

Do this before you enrol any agent: the server's database holds their identities. Once maintenant runs on the Volume, docker volume rm maintenant_maintenant-data removes the old copy.

Size the volume for migrations, not just for the data

Schema migrations rebuild tables in place and transiently need several times the size of the database. A volume sized to the current database will fail an upgrade. 10 GiB is a sane floor.

Watching more than one host? Consider PostgreSQL

The server holds the agents' identity and enrolment, the one thing they cannot rebuild on their own. On the default SQLite file, losing that Droplet means re-enrolling every host by hand. Pointing the server at a PostgreSQL you already run makes the instance replaceable. See PostgreSQL storage.


Monitoring several Droplets over the VPC

Every Droplet lands in its region's default VPC, a /20 range such as 10.102.0.0/20, and VPC traffic does not count against bandwidth. That is where agent traffic belongs.

A Droplet reads its own private address from the metadata service:

curl -s http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address

Enrolling hosts needs the Personal edition

Remote hosts require the Personal edition or above (MAINTENANT_LICENSE_KEY). Personal covers up to 20 hosts, Pro has no cap. On Community the server does not open the agent port: it logs agent gRPC listener not started: agents need the personal edition or above.

The gRPC listener defaults to 127.0.0.1:8443. Inside the container that address cannot be reached from the network, for the same reason as the HTTP port. The server must listen on all interfaces in the container, and the private address must be published in the Compose file. Edit /opt/maintenant/compose.yml:

   maintenant:
     ports:
       - "127.0.0.1:8080:8080"
+      - "10.102.0.2:8443:8443"
     volumes:
+      - /opt/maintenant/tls:/etc/maintenant/tls:ro
       - maintenant-data:/data
     environment:
       MAINTENANT_ADDR: "0.0.0.0:8080"
       MAINTENANT_DB: "/data/maintenant.db"
+      MAINTENANT_LICENSE_KEY: "<your license key>"
+      MAINTENANT_GRPC_LISTEN: "0.0.0.0:8443"
+      MAINTENANT_GRPC_URL: "grpcs://maintenant.internal.example.com:8443"
+      MAINTENANT_GRPC_TLS_CERT: "/etc/maintenant/tls/server.crt"
+      MAINTENANT_GRPC_TLS_KEY: "/etc/maintenant/tls/server.key"

Publish on the private address (10.102.0.2), never as a bare 8443:8443. That address must exist on the host when the container starts, or Docker refuses it with cannot assign requested address. Apply with docker compose -f /opt/maintenant/compose.yml up -d.

The certificate and key are read at startup by uid 65534, so they must be readable by it, and a renewed certificate is only picked up when the container restarts. The certificate has to cover the name in MAINTENANT_GRPC_URL. A DNS-01 ACME certificate works for a name that only resolves privately, as long as the domain is yours. With a private CA instead, issue the server certificate from it and give the agents the CA, as shown below.

Then enrol each other Droplet. In the web UI, open Agents and click Generate enrollment token: the modal shows the token once, with a ready-made command per environment and the server address taken from MAINTENANT_GRPC_URL. Run it on the host, pointing at the private address:

docker run -d \
  --name maintenant-agent \
  --restart unless-stopped \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v /proc:/host/proc:ro \
  -v /etc/os-release:/host/etc/os-release:ro \
  -v maintenant-agent-data:/var/lib/maintenant \
  ghcr.io/kolapsis/maintenant:latest \
  --mode=agent \
  --server=grpcs://maintenant.internal.example.com:8443 \
  --enrollment-token=mnt_enr_XXXXXXXXXXXXXXXX

With a private CA, add these two lines to the agent command (the generated one does not include them):

  -e MAINTENANT_CA_CERT=/etc/maintenant/ca.pem \
  -v /opt/maintenant/ca.pem:/etc/maintenant/ca.pem:ro \

The file must be readable by uid 65534: an unreadable or invalid CA makes the agent stop at start with failed to load extra CA bundle.

Allow the gRPC port between tagged Droplets rather than by IP, so a replaced Droplet needs no rule change:

doctl compute firewall add-rules <firewall-id> \
  --inbound-rules "protocol:tcp,ports:8443,tag:maintenant"

Private does not mean plaintext

Publishing on the VPC address keeps the listener off the public internet, but the agent still speaks TLS. Use a certificate that covers the internal hostname. Do not reach for --grpc-insecure-skip-tls-verify outside a lab. The full matrix of TLS modes is in Agent Setup → Step 1.

A VPC does not span regions

A VPC network lives in one region. Agents in another region need VPC peering, or they reach the server over its public address like any other remote host.


Load Balancer

A Load Balancer hides target failures by design: after 5 failed checks it pulls a Droplet out of the pool and the public endpoint keeps answering 200. Checking only the Load Balancer tells you nothing until every target is down.

Configure two layers of endpoint checks:

Check Target What it tells you
Public https://app.example.com The service is reachable for users.
Per-target http://10.102.0.3:8080/health on each Droplet Which backend is down behind a still-green Load Balancer.

The per-target checks run over the VPC from the maintenant Droplet, so they need no public exposure. Add them from Endpoints → New endpoint; see Endpoint Monitoring.

The Load Balancer's own defaults are worth knowing when you compare its verdict to maintenant's: an HTTP check on / every 10 seconds, 5 failures to remove a target, 3 successes to bring it back. Its IP comes from:

doctl compute load-balancer get <lb-id> --format IP --no-header

gRPC backends need a TCP check

Load Balancer HTTP health checks speak HTTP/1.1, so a gRPC service must be checked over TCP. This applies to a Load Balancer fronting maintenant's own gRPC listener.


Kubernetes on DOKS

helm install maintenant ./deploy/helm/maintenant \
  -n maintenant --create-namespace \
  --set persistence.storageClass=do-block-storage-retain \
  --set persistence.size=10Gi

The DigitalOcean CSI driver installs four storage classes. do-block-storage is the cluster default and deletes the volume with the PVC; do-block-storage-retain keeps it, which is what you want under a database you would rather not lose to a helm uninstall. XFS variants (do-block-storage-xfs, do-block-storage-xfs-retain) exist if you prefer XFS.

Two DOKS specifics:

  • volumes are ReadWriteOnce, which suits maintenant: SQLite takes a single writer and the deployment never scales past one replica;
  • a PVC with no size request gets 16 GiB, and anything under 1 GiB is raised to 1 GiB.

Full RBAC, namespace filtering and Helm values are in the Kubernetes Guide.


Backups and snapshots

A Droplet snapshot copies the disk while the database is being written to. For SQLite in WAL mode that is a torn copy, not a backup. DigitalOcean says as much: powering the Droplet off first is recommended precisely because databases do not guarantee on-disk consistency otherwise.

The image does not ship a sqlite3 client. Install one on the Droplet and copy the database from the file behind the Volume, which works while maintenant keeps running:

apt-get install -y sqlite3
sqlite3 -readonly /mnt/maintenant_data/maintenant/maintenant.db \
  ".backup '/mnt/maintenant_data/maintenant/maintenant.backup.db'"

# Then snapshot the Droplet, or just the volume
doctl compute droplet-action snapshot <droplet-id> \
  --snapshot-name "maintenant-$(date -u +%F)" --wait
doctl compute volume snapshot <volume-id> \
  --snapshot-name "maintenant-data-$(date -u +%F)"

Without a client, stop the stack for the few seconds the copy takes and copy the database files together:

docker compose -f /opt/maintenant/compose.yml stop
mkdir -p /root/maintenant-$(date -u +%F)
cp -a /mnt/maintenant_data/maintenant/maintenant.db* /root/maintenant-$(date -u +%F)/
docker compose -f /opt/maintenant/compose.yml start

Where to put the copy

Spaces (S3-compatible object storage) is the cheapest off-Droplet destination and reachable from the same region without leaving DigitalOcean's network.