Skip to content

Kubernetes Guide

maintenant runs natively on Kubernetes with read-only RBAC, namespace filtering, and workload-level monitoring out of the box.


Deployment

helm install maintenant ./deploy/helm/maintenant \
  -n maintenant --create-namespace

This is the recommended approach for production clusters. See the Helm section below for full options.

On a managed cluster, name the storage class your provider's CSI driver installs, or the PVC stays Pending:

Cluster Value
Hetzner (kube-hetzner, hetzner-k3s, Talos) --set persistence.storageClass=hcloud-volumes, see Kubernetes on Hetzner
DigitalOcean (DOKS) --set persistence.storageClass=do-block-storage-retain, see Kubernetes on DOKS
Scaleway (Kapsule) --set persistence.storageClass=sbs-default, see Kubernetes on Kapsule
OVHcloud (MKS) --set persistence.storageClass=csi-cinder-high-speed, see Kubernetes on Managed Kubernetes
Vultr (VKE) --set persistence.storageClass=vultr-block-storage-retain, see Kubernetes on VKE

Raw manifests

Apply the provided manifests:

kubectl create namespace maintenant
kubectl apply -f deploy/kubernetes/

This creates:

Resource Description
ServiceAccount maintenant — identity for API access
ClusterRole Read-only access to pods, logs, services, events, workloads, and metrics
ClusterRoleBinding Binds the role to the service account
Deployment Single replica with security hardening
PersistentVolumeClaim 10 Gi for SQLite storage — migrations rebuild tables in place and need several times the database size
Service ClusterIP on port 80

RBAC Permissions

maintenant requests the minimum permissions needed for monitoring:

rules:
  # Core resources — read-only
  - apiGroups: [""]
    resources: ["pods", "pods/log", "services", "namespaces", "events"]
    verbs: ["get", "list", "watch"]
  # Workloads — read-only
  - apiGroups: ["apps"]
    resources: ["deployments", "statefulsets", "daemonsets", "replicasets"]
    verbs: ["get", "list", "watch"]
  # Metrics — read-only
  - apiGroups: ["metrics.k8s.io"]
    resources: ["pods"]
    verbs: ["get", "list"]

maintenant never creates, modifies, or deletes any resource in your cluster.

Metrics Server required

Resource metrics (CPU/memory) require metrics-server to be installed in the cluster. Container monitoring works without it.


Security Hardening

The default deployment includes:

securityContext:
  runAsNonRoot: true
  runAsUser: 65534       # nobody
  runAsGroup: 65534
  fsGroup: 65534
containers:
  - securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]

The container runs as nobody (uid 65534) with an immutable root filesystem and all Linux capabilities dropped. A /tmp emptyDir is mounted for SQLite WAL temporary files since the root filesystem is read-only.

This mirrors the Docker Compose hardening (read_only: true, no-new-privileges, non-root user). See Security for the full container security reference.


Namespace Filtering

By default, maintenant monitors all namespaces. Use environment variables to restrict scope:

Allowlist

Only monitor specific namespaces:

env:
  - name: MAINTENANT_K8S_NAMESPACES
    value: "default,production,staging"

Blocklist

Monitor all namespaces except specific ones:

env:
  - name: MAINTENANT_K8S_EXCLUDE_NAMESPACES
    value: "kube-system,kube-public,cert-manager"

System namespaces

kube-system and kube-public are excluded by default when using the blocklist. You do not need to add them explicitly.

If both MAINTENANT_K8S_NAMESPACES and MAINTENANT_K8S_EXCLUDE_NAMESPACES are set, the allowlist takes precedence.


Workload Monitoring

maintenant groups pods by their owning workload:

Workload What maintenant tracks
Deployment Replica count, ready pods, rollout status
StatefulSet Ordered pod states, persistent volume claims
DaemonSet Node coverage, desired vs ready counts

Each workload appears as a single entry in the dashboard with aggregated health status. Individual pods are accessible in the detail view.


Runtime Detection

maintenant auto-detects Kubernetes in this order:

  1. MAINTENANT_RUNTIME=kubernetes environment variable (explicit override)
  2. KUBERNETES_SERVICE_HOST environment variable (set automatically by Kubernetes for in-cluster pods)
  3. KUBECONFIG environment variable or ~/.kube/config file (for out-of-cluster development)

To force Kubernetes mode:

env:
  - name: MAINTENANT_RUNTIME
    value: "kubernetes"

Health Probes

The deployment includes startup, liveness and readiness probes:

startupProbe:
  httpGet:
    path: /api/v1/health
    port: http
  periodSeconds: 10
  failureThreshold: 60
livenessProbe:
  httpGet:
    path: /api/v1/health
    port: http
  initialDelaySeconds: 5
  periodSeconds: 30
readinessProbe:
  httpGet:
    path: /api/v1/health
    port: http
  initialDelaySeconds: 3
  periodSeconds: 10

Do not drop the startup probe

Schema migrations run before the HTTP listener starts, so /api/v1/health stays silent for their whole duration — minutes on a large database, and longer for the one-time UUID conversion. Without a startup probe the liveness probe kills the pod about 95 s in, leaving the migration half-applied and the schema marked dirty, and the same thing happens on every restart. The failureThreshold: 60 above grants 10 minutes; raise it if an upgrade needs more.

Do not make the probe fail on a database outage

With an external PostgreSQL, /api/v1/health deliberately answers 200 even when the database is unreachable — the outage is reported in storage.connected. A probe that failed on it would restart the pod exactly when the database needs to be left alone, and restarting fixes nothing: the instance recovers on its own once the database answers. If you want to alert on it, read storage.connected; do not wire it to a probe. See PostgreSQL storage.


Resource Limits

Default resource requests and limits:

resources:
  requests:
    cpu: 50m
    memory: 64Mi
  limits:
    cpu: 500m
    memory: 256Mi

Adjust based on the number of monitored workloads. maintenant is lightweight — 50-100 workloads run comfortably within these limits.


Scaling Considerations

Single replica only

maintenant uses SQLite with a single-writer pattern. The deployment strategy is set to Recreate — do not scale beyond 1 replica.

For high availability, ensure your PersistentVolumeClaim uses a storage class with adequate durability.


Exposing the Dashboard

Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: maintenant
  namespace: maintenant
spec:
  rules:
    - host: now.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: maintenant
                port:
                  name: http

Port Forward (Development)

kubectl port-forward -n maintenant svc/maintenant 8080:80

Open http://localhost:8080.


Helm

The chart is located in deploy/helm/maintenant/.

Minimal install

helm install maintenant ./deploy/helm/maintenant \
  -n maintenant --create-namespace

With Ingress

helm install maintenant ./deploy/helm/maintenant \
  -n maintenant --create-namespace \
  --set ingress.enabled=true \
  --set ingress.host=maintenant.example.com \
  --set ingress.className=nginx

With TLS

# values-prod.yaml
ingress:
  enabled: true
  className: nginx
  host: maintenant.example.com
  tls:
    - secretName: maintenant-tls
      hosts:
        - maintenant.example.com
helm install maintenant ./deploy/helm/maintenant \
  -n maintenant --create-namespace \
  -f values-prod.yaml

Enterprise license

Pass the license key directly:

helm install maintenant ./deploy/helm/maintenant \
  --set license.key=YOUR_LICENSE_KEY

Or reference an existing secret (recommended for GitOps):

kubectl create secret generic maintenant-license \
  --from-literal=license-key=YOUR_LICENSE_KEY \
  -n maintenant

helm install maintenant ./deploy/helm/maintenant \
  --set license.existingSecret=maintenant-license

Key values

Value Default Description
image.tag "" (chart appVersion) Image tag to deploy
runtime kubernetes kubernetes or docker
persistence.size 1Gi SQLite volume size
persistence.storageClass "" Storage class (cluster default if empty)
persistence.existingClaim "" Use an existing PVC
ingress.enabled false Enable Ingress resource
ingress.host maintenant.example.com Ingress hostname
ingress.className "" Ingress class
license.key "" Enterprise license key
license.existingSecret "" Existing secret name for the license key
resources see values.yaml CPU/memory requests and limits

Upgrade

helm upgrade maintenant ./deploy/helm/maintenant -n maintenant

Uninstall

helm uninstall maintenant -n maintenant

PVC not deleted on uninstall

Helm does not delete PersistentVolumeClaims on uninstall to prevent accidental data loss. Delete it manually if needed: kubectl delete pvc maintenant-data -n maintenant