Hull replaces the need for hundreds of app-specific charts. It's designed for GitOps-managed homelabs and production clusters where you want convention over configuration without the footguns.
8 lines of YAML to deploy an app with a Service, Ingress, TLS, DNS, and auto-probes. Zero hidden env vars. Zero hidden volume mounts. Zero surprise security contexts.
| Principle | |
|---|---|
| 1 | Convention over configuration — sane defaults that work for 90% of apps with zero overrides |
| 2 | No footguns — never silently break a deployment with hidden defaults |
| 3 | Explicit is better than implicit — if a feature is enabled, the user asked for it |
| 4 | Multi-container first class — sidecars are as easy as the main container |
| 5 | GitOps native — everything is declarative YAML, no imperative setup steps |
helm install my-app oci://ghcr.io/kampe/hull --version 1.0.0 -f values.yamlimage:
repository: glanceapp/glance
tag: latest
service:
main:
port: 8080
ingress:
main:
hostname: glance.example.comThat's it. This gives you:
- A
Deploymentwith one container running the image - A
ClusterIPService on port 8080 - An
Ingresswith TLS (via cert-manager) and external-dns - Auto-generated HTTP probes on port 8080
- No hidden env vars, no hidden volume mounts, no surprise security contexts
Hull supports single containers, sidecars, and init containers — all with the same configuration surface.
Main container:
image:
repository: nginx
tag: latest
ports:
- name: http
containerPort: 8080
env:
APP_ENV: production
DATABASE_URL:
secretKeyRef:
name: app-secrets
key: database-urlSidecars run in the same pod:
sidecars:
redis:
image:
repository: redis
tag: "7"
port: 6379
chrome:
image:
repository: gcr.io/zenika-hub/alpine-chrome
tag: "124"
args: ["--no-sandbox", "--headless", "--remote-debugging-port=9222"]
port: 9222
env:
CHROME_FLAGS: "--disable-gpu"Init containers:
initContainers:
migrations:
image:
repository: my-app
tag: latest
command: ["sh", "-c", "run-migrations"]
env:
DATABASE_URL:
secretKeyRef:
name: app-secrets
key: database-urlProbes are automatically generated based on your ports:
- HTTP ports (named
http,web,main, or on common ports like 80, 8080, 3000) →httpGetprobes on/ - Non-HTTP ports (like redis 6379, mongo 27017) →
tcpSocketprobes - Startup probes get generous defaults:
failureThreshold: 30,periodSeconds: 5(allows 2.5 minutes to start)
No ports defined? No probes generated. No errors. No footguns.
Override or disable:
probes:
# Disable all probes
enabled: false
# Or customize individually
liveness:
path: /healthz
periodSeconds: 15
readiness:
type: exec
command: ["pg_isready"]
startup:
enabled: falseService definitions can infer target ports from your container ports, but you still opt in by defining the service you want to expose:
# Minimal: just set the port
service:
main:
port: 8080
# Multiple ports on one Service
service:
main:
ports:
http:
port: 80
targetPort: http
grpc:
port: 9090
targetPort: grpc
# Expose a sidecar on its own Service
service:
redis:
targetContainer: redis
ports:
redis:
port: 6379
targetPort: redis
# Full options
service:
main:
port: 8080
type: LoadBalancer
loadBalancerIP: 10.0.0.1
annotations:
metallb.universe.tf/address-pool: defaultIngress configuration is designed to eliminate boilerplate. All the common patterns are built in as simple toggles.
ingress:
main:
hostname: app.example.com
certManager: true # adds cert-manager.io/cluster-issuer: vault-issuer
externalDns: true # adds external-dns annotation
authentik: true # adds full Authentik forward-auth annotation block
proxyBodySize: "0" # adds nginx proxy-body-size annotation
# Custom annotations are merged with generated ones
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"Multiple ingresses:
ingress:
main:
hostname: app.example.com
api:
hostname: api.example.com
path: /api
service: mainCustom cluster issuer:
ingress:
main:
hostname: app.example.com
clusterIssuer: vault-issuer # default: vault-issuerDisable cert-manager or external-dns:
ingress:
main:
hostname: app.example.com
certManager: false
externalDns: falseSwitch from Ingress to Gateway API with a single flag:
routeType: gateway
gatewayName: main-gateway
gatewayNamespace: gateway-system
gatewaySectionName: https
ingress:
main:
hostname: app.example.comThis generates an HTTPRoute instead of an Ingress. The ingress block syntax is the same — only the rendered resource changes.
Per-route gateway overrides are also supported:
ingress:
main:
hostname: app.example.com
gatewayName: internal-gateway
api:
hostname: api.example.com
gatewayName: external-gatewayNamed volumes with predictable naming (<release>-<volume-name>):
persistence:
# PersistentVolumeClaim (default)
data:
size: 5Gi
mountPath: /data
storageClass: longhorn # optional
accessMode: ReadWriteOnce # default
# NFS
media:
type: nfs
server: 192.168.1.100
path: /mnt/media
mountPath: /media
# EmptyDir
cache:
type: emptyDir
mountPath: /cache
# ConfigMap
config:
type: configmap
objectName: app-settings
mountPath: /etc/app/config.yml
subPath: config.yml
items:
- key: config.yml
path: config.yml
# Secret
certs:
type: secret
objectName: vault-ca
mountPath: /etc/ssl/certs/
items:
- key: ca.crt
path: ca.crt
# HostPath
device:
type: hostPath
hostPath: /dev/bus/usb
hostPathType: Directory
mountPath: /dev/bus/usb
# StatefulSet volumeClaimTemplate
db:
type: volumeClaimTemplate
size: 20Gi
mountPath: /var/lib/postgresql/dataTarget specific containers:
persistence:
data:
size: 5Gi
mountPath: /data
# Mounts in all containers (default)
meili-data:
size: 2Gi
mountPath: /meili_data
containers: [meilisearch] # Only mount in the meilisearch sidecarconfigMaps:
app-settings:
data:
config.yml: |
server:
port: 80
logging:
level: info
simple-key: simple-valueConfigMaps are named <release>-<name>. Mount them via the persistence section using type: configmap.
Three presets to avoid security context boilerplate:
| Preset | What it does |
|---|---|
default |
No security context at all. Let the image decide. (This is the default.) |
restricted |
runAsNonRoot, readOnlyRootFilesystem, drop ALL capabilities, allowPrivilegeEscalation: false |
root |
runAsUser: 0, runAsGroup: 0 |
securityPreset: restrictedOverride on top of a preset:
securityPreset: restricted
securityContext:
runAsUser: 1000Pod-level security:
podSecurityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatchmetrics:
enabled: true
port: http # port name to scrape
path: /metrics
interval: 30s
labels:
team: platformGenerates a ServiceMonitor with instance: primary label by default (configurable via labels).
serviceAccount:
create: true
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::role/my-role
rbac:
create: true
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]networkPolicy:
enabled: true
# Default: allows ingress on service ports, egress to DNS + everywhere
# Override with custom rules:
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- port: 8080# Default: Deployment
workloadType: Deployment
# StatefulSet (auto-sets serviceName)
workloadType: StatefulSetAll standard pod spec fields are pass-through:
nodeSelector:
kubernetes.io/arch: amd64
tolerations:
- key: dedicated
operator: Equal
value: apps
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
terminationGracePeriodSeconds: 60
hostNetwork: false
dnsPolicy: ClusterFirst
priorityClassName: high-priorityThree styles, all in the same env block:
env:
# Plain value
APP_ENV: production
# From Secret
DATABASE_URL:
secretKeyRef:
name: app-secrets
key: database-url
# From ConfigMap
CONFIG_PATH:
configMapKeyRef:
name: app-config
key: config-path
# From field reference (downward API)
POD_NAME:
fieldRef:
fieldPath: metadata.name
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secretsimage:
repository: glanceapp/glance
tag: latest
ports:
- name: http
containerPort: 8080
service:
main:
port: 8080
ingress:
main:
hostname: glance.kampe.kluster
certManager: true
externalDns: trueimage:
repository: ghcr.io/karakeep-app/karakeep
tag: latest
ports:
- name: http
containerPort: 3000
sidecars:
chrome:
image:
repository: gcr.io/zenika-hub/alpine-chrome
tag: "124"
args: ["--no-sandbox", "--headless", "--remote-debugging-port=9222"]
port: 9222
meilisearch:
image:
repository: getmeili/meilisearch
tag: v1.13
port: 7700
env:
MEILI_NO_ANALYTICS: "true"
env:
BROWSER_WEB_URL: "http://localhost:9222"
MEILI_ADDR: "http://localhost:7700"
service:
main:
port: 3000
ingress:
main:
hostname: bookmarks.kampe.kluster
certManager: true
externalDns: true
persistence:
data:
size: 5Gi
mountPath: /data
meili:
size: 2Gi
mountPath: /meili_data
containers: [meilisearch]
securityPreset: rootimage:
repository: ghcr.io/advplyr/audiobookshelf
tag: latest
ports:
- name: http
containerPort: 80
service:
main:
port: 80
ingress:
main:
hostname: audiobookshelf.kampe.kluster
certManager: true
externalDns: true
authentik: true
persistence:
config:
size: 5Gi
mountPath: /config
media:
type: nfs
server: 192.168.1.100
path: /mnt/media/media
mountPath: /audiobooks
metrics:
enabled: true
path: /metrics
port: http
securityPreset: rootThis chart is intentionally opinionated about what it won't do, based on real-world pain points with other general-purpose charts:
| Anti-Pattern | Hull's Position |
|---|---|
| Inject hidden env vars (TZ, PUID, PGID, UMASK, S6_READ_ONLY_ROOT) | Never. Your env block is the only source of env vars. |
| Set fsGroup by default | Never. Specify it yourself or don't. |
| Auto-mount /tmp, /dev/shm, /var/logs, /shared | Never. No hidden volume mounts. |
| Require probes on every container | Never. No probes if no ports. No errors either. |
| Drop ALL capabilities by default | Never. Too many images break. Use securityPreset: restricted to opt in. |
| Set readOnlyRootFilesystem by default | Never. Use securityPreset: restricted to opt in. |
| Inject Traefik middleware | Never. This chart is ingress-controller-agnostic. |
| Require disabling features you don't use | Never. No integrations.traefik.enabled: false boilerplate. |
chart/
├── Chart.yaml
├── values.yaml # Full documented defaults
├── values.schema.json # JSON Schema for validation
├── templates/
│ ├── _helpers.tpl # Template helpers
│ ├── deployment.yaml # Deployment or StatefulSet
│ ├── service.yaml # One per service definition
│ ├── ingress.yaml # Ingress resources
│ ├── httproute.yaml # Gateway API HTTPRoutes
│ ├── configmap.yaml # User-defined ConfigMaps
│ ├── pvc.yaml # PersistentVolumeClaims
│ ├── servicemonitor.yaml # Prometheus ServiceMonitor
│ ├── serviceaccount.yaml # Optional ServiceAccount
│ ├── rbac.yaml # Optional ClusterRole/Binding
│ └── networkpolicy.yaml # Optional NetworkPolicy
└── ci/ # CI test values
├── minimal-values.yaml
├── single-container-values.yaml
├── multi-container-values.yaml
├── ingress-values.yaml
├── persistence-values.yaml
├── metrics-values.yaml
├── security-values.yaml
├── gateway-values.yaml
└── full-values.yaml
Run the test suite locally:
# Requires: helm
./tests/test_chart.shThe test suite validates:
- Minimal values (image only — no footguns)
- Single container with ports, service, and ingress
- Multi-container with sidecars
- All Authentik annotation presets
- Every persistence type (PVC, NFS, emptyDir, configmap, secret, hostPath)
- ServiceMonitor generation
- All security presets
- Gateway API HTTPRoute generation
- Full-featured deployment with everything enabled
- Anti-pattern tests (nothing injected that wasn't asked for)
- All CI values files template cleanly
- Edge cases (probes disabled, StatefulSet)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app
namespace: argocd
spec:
source:
chart: hull
repoURL: ghcr.io/kampe
targetRevision: 1.0.0
helm:
valuesObject:
image:
repository: my-app
tag: latest
service:
main:
port: 8080
ingress:
main:
hostname: my-app.example.com
destination:
server: https://kubernetes.default.svc
namespace: my-appHull is published as an OCI artifact, so Renovate can auto-detect and update it. Add to your renovate.json:
{
"helm-values": {
"fileMatch": ["values\\.yaml$"]
}
}- Fork the repo
- Create a feature branch
- Add/modify templates
- Add test cases to
tests/test_chart.shand CI values tochart/ci/ - Run
./tests/test_chart.shand confirm all tests pass - Open a PR
MIT