Skip to main content

Kubernetes with Helm

Deploy UnDercontrol on Kubernetes using Helm charts. Supports single-node homelab setups with SQLite all the way to production clusters with PostgreSQL.

Stack Overview

  • Backend: Go API server (Deployment + PVC)
  • Frontend: Vite SPA served by nginx (Deployment)
  • Database: SQLite (default) or PostgreSQL (external)
  • Storage: PersistentVolumeClaim for data + file uploads
  • Exposure: ClusterIP, NodePort, or Ingress

Prerequisites

  • Kubernetes cluster (1.19+)
  • Helm 3 installed
  • kubectl configured and connected to your cluster
  • A StorageClass for persistent volumes (e.g., local-path for k3s)
# Verify
kubectl version --client
helm version

Quick Start

Option 1: From Helm Repository

helm repo add undercontrol https://oatnil-top.github.io/undercontrol-helm
helm repo update

helm install undercontrol undercontrol/undercontrol \
--create-namespace --namespace undercontrol \
--set backend.jwt.secret=my-secret-key \
--set backend.licenseToken=my-license-token

Option 2: From OCI Registry

helm install undercontrol oci://ghcr.io/oatnil-top/undercontrol \
--create-namespace --namespace undercontrol \
--set backend.jwt.secret=my-secret-key \
--set backend.licenseToken=my-license-token

This deploys both backend and frontend with SQLite, ClusterIP services, and a 5Gi PVC.

Configuration

All configuration is done through Helm values. You can pass individual --set flags or provide a values.yaml file:

helm install undercontrol undercontrol/undercontrol \
--create-namespace --namespace undercontrol \
-f my-values.yaml

Image Configuration

backend:
image:
repository: lintao0o0/undercontrol-backend
tag: "0.1.0" # defaults to Chart appVersion
pullPolicy: IfNotPresent

frontend:
image:
repository: lintao0o0/undercontrol-vite-app
tag: "0.1.0"
pullPolicy: IfNotPresent

# If using a private registry
imagePullSecrets:
- name: my-registry-secret

Database

SQLite (Default)

No extra configuration needed. Data is stored on the backend PVC.

backend:
database:
type: sqlite

PostgreSQL

Point to an existing PostgreSQL instance:

backend:
database:
type: postgres
postgres:
host: my-postgres-host
port: 5432
user: postgres
password: my-password
database: undercontrol
sslMode: disable

The password is stored in a Kubernetes Secret automatically. To use an existing Secret instead:

backend:
existingSecret: my-undercontrol-secrets

The Secret should contain these keys: jwt-secret, postgres-password, license-token, and optionally s3-access-key-id, s3-secret-access-key.

Service Exposure

ClusterIP (Default)

Services are only accessible within the cluster. Use with Ingress or kubectl port-forward:

kubectl port-forward -n undercontrol svc/undercontrol-frontend 8080:80
kubectl port-forward -n undercontrol svc/undercontrol-backend 8081:8080

NodePort

Access services directly on node IPs:

backend:
service:
type: NodePort
nodePort: 30880

frontend:
service:
type: NodePort
nodePort: 30800

Then access at http://<node-ip>:30800 (frontend) and http://<node-ip>:30880 (backend API).

Ingress

Route traffic through an Ingress controller with host-based routing:

ingress:
enabled: true
className: nginx # or traefik, etc.
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: ud.example.com
paths:
- path: /api
pathType: Prefix
service: backend
- path: /
pathType: Prefix
service: frontend
tls:
- secretName: ud-tls
hosts:
- ud.example.com

Persistent Storage

The backend PVC stores the SQLite database and uploaded files:

backend:
persistence:
enabled: true
size: 5Gi
storageClass: local-path # Use your cluster's StorageClass
accessMode: ReadWriteOnce

Set persistence.enabled: false if you're using PostgreSQL and S3 (no local storage needed).

S3 / R2 Object Storage

Offload file uploads to S3-compatible storage:

backend:
s3:
enabled: true
endpoint: https://your-s3-endpoint.com
region: auto
bucket: undercontrol-uploads
accessKeyId: your-access-key
secretAccessKey: your-secret-key
forcePathStyle: true # Required for MinIO / Cloudflare R2

Resources

Default resource limits:

backend:
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi

frontend:
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi

Full Values Reference

ParameterDescriptionDefault
backend.enabledDeploy backend componenttrue
backend.replicaCountBackend replicas1
backend.image.repositoryBackend imagelintao0o0/undercontrol-backend
backend.image.tagBackend image tagChart appVersion
backend.service.typeBackend service typeClusterIP
backend.service.portBackend service port8080
backend.service.nodePortBackend NodePort (when type=NodePort)-
backend.environmentproduction or developmentproduction
backend.jwt.secretJWT signing secret""
backend.jwt.expirationMinutesJWT token TTL60
backend.database.typesqlite or postgressqlite
backend.database.postgres.*PostgreSQL connection settings-
backend.s3.enabledEnable S3 storagefalse
backend.s3.*S3 connection settings-
backend.dataPathData directory in container/data
backend.persistence.enabledCreate PVC for datatrue
backend.persistence.sizePVC size5Gi
backend.persistence.storageClassStorageClass name"" (default)
backend.corsAllowedOriginsCORS origins""
backend.licenseTokenLicense token""
backend.existingSecretUse external Secret""
backend.extraEnvAdditional env vars[]
frontend.enabledDeploy frontend componenttrue
frontend.replicaCountFrontend replicas1
frontend.image.repositoryFrontend imagelintao0o0/undercontrol-vite-app
frontend.image.tagFrontend image tagChart appVersion
frontend.service.typeFrontend service typeClusterIP
frontend.service.portFrontend service port80
frontend.service.nodePortFrontend NodePort-
frontend.extraEnvAdditional env vars[]
ingress.enabledEnable Ingressfalse
ingress.classNameIngress class""
ingress.hostsIngress host rules[]
ingress.tlsIngress TLS config[]

Example Deployments

Minimal (SQLite + NodePort)

Best for trying out UnDercontrol on a single node or homelab:

# values-minimal.yaml
backend:
jwt:
secret: change-me-in-production
licenseToken: your-license-token
service:
type: NodePort
nodePort: 30880
persistence:
storageClass: local-path

frontend:
service:
type: NodePort
nodePort: 30800
helm install undercontrol undercontrol/undercontrol \
--create-namespace --namespace undercontrol \
-f values-minimal.yaml

Production (PostgreSQL + Ingress + S3)

# values-production.yaml
backend:
replicaCount: 2
existingSecret: undercontrol-secrets # Pre-create with jwt-secret, postgres-password, etc.
database:
type: postgres
postgres:
host: postgres.database.svc.cluster.local
port: 5432
user: undercontrol
database: undercontrol
sslMode: require
s3:
enabled: true
endpoint: https://s3.amazonaws.com
region: us-east-1
bucket: undercontrol-uploads
persistence:
enabled: false # Not needed with PostgreSQL + S3
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi

frontend:
replicaCount: 2

ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: app.example.com
paths:
- path: /api
pathType: Prefix
service: backend
- path: /
pathType: Prefix
service: frontend
tls:
- secretName: undercontrol-tls
hosts:
- app.example.com
# Create the secret first
kubectl create namespace undercontrol
kubectl create secret generic undercontrol-secrets \
--namespace undercontrol \
--from-literal=jwt-secret=your-jwt-secret \
--from-literal=postgres-password=your-db-password \
--from-literal=license-token=your-license \
--from-literal=s3-access-key-id=your-key \
--from-literal=s3-secret-access-key=your-secret

helm install undercontrol undercontrol/undercontrol \
--namespace undercontrol \
-f values-production.yaml

Upgrading

helm repo update
helm upgrade undercontrol undercontrol/undercontrol \
--namespace undercontrol \
-f my-values.yaml

To update just the image version:

helm upgrade undercontrol undercontrol/undercontrol \
--namespace undercontrol \
--reuse-values \
--set backend.image.tag=0.59.0 \
--set frontend.image.tag=0.59.0

Uninstalling

helm uninstall undercontrol --namespace undercontrol

Note: PersistentVolumeClaims are not deleted automatically. To remove data:

kubectl delete pvc -n undercontrol -l app.kubernetes.io/instance=undercontrol
kubectl delete namespace undercontrol

Troubleshooting

Check pod status

kubectl get pods -n undercontrol
kubectl describe pod -n undercontrol <pod-name>
kubectl logs -n undercontrol <pod-name>

Backend won't start

  • Verify the license token is set (LICENSE_TOKEN env var)
  • Check database connectivity if using PostgreSQL
  • Review logs: kubectl logs -n undercontrol -l app.kubernetes.io/component=backend

Frontend can't reach backend

  • Ensure both services are running: kubectl get svc -n undercontrol
  • If using Ingress, verify the /api path routes to the backend service
  • If using NodePort, ensure CORS is configured: backend.corsAllowedOrigins

Source Code