Kubernetes Deployment
Mezite does not publish a container image or a Helm chart. Build an image from the release archive as described on the Podman / Docker page, push it to a registry your cluster can pull from, then apply the manifests below.
Prerequisites
- Kubernetes 1.27+
- A registry holding your
mezhubimage - A PostgreSQL 16 instance (managed or self-hosted)
Namespace, Config and Secrets
mezhub reads its configuration from a YAML file passed with --config; secrets come in as environment variables, which take precedence over the
file. Mount the config from a ConfigMap and keep the
passphrase and keys in a Secret.
apiVersion: v1
kind: Namespace
metadata:
name: mezite
---
apiVersion: v1
kind: ConfigMap
metadata:
name: mezite
namespace: mezite
data:
mezite.yaml: |
cluster_name: mezite
data_dir: /var/lib/mezite
log:
level: info
format: json
database:
driver: postgres
host: postgres
port: 5432
name: mezite
user: mezite
sslmode: require
auth:
enabled: true
listen_addr: "0.0.0.0:3025"
proxy:
enabled: true
listen_addr: "0.0.0.0:3080"
ssh_listen_addr: "0.0.0.0:3023"
tunnel_listen_addr: "0.0.0.0:3024"
max_conns_per_ip: 100
---
apiVersion: v1
kind: Secret
metadata:
name: mezite
namespace: mezite
type: Opaque
stringData:
# Required. Without a passphrase the CA signing keys are stored unencrypted.
ca-key-passphrase: change-me
db-password: secret
# Optional; both are hex-encoded. Omit to leave the feature off.
recording-enc-key: ""
audit-hmac-key: "" Deployment and Service
The container listens on 3025 (auth gRPC), 3080 (HTTPS / web), 3023 (SSH)
and 3024 (agent tunnel). Probes hit /healthz and
/readyz on the HTTPS port — the scheme is HTTPS,
because the proxy terminates TLS itself.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mezhub
namespace: mezite
labels:
app.kubernetes.io/name: mezite
spec:
replicas: 1
# Recreate, not RollingUpdate: the mezhub-data PVC below is ReadWriteOnce,
# so a surge Pod could not mount it while the old Pod still holds it, and
# maxUnavailable: 0 would block the old Pod from terminating — a deadlocked
# rollout. This trades a short outage for an upgrade that completes.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: mezite
template:
metadata:
labels:
app.kubernetes.io/name: mezite
spec:
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 10001 # matches the UID created in the Containerfile
runAsGroup: 10001
fsGroup: 10001
containers:
- name: mezhub
image: registry.example.com/mezite:latest # your image
args: ["--config=/etc/mezite/mezite.yaml"]
ports:
- { name: https, containerPort: 3080 }
- { name: ssh, containerPort: 3023 }
- { name: tunnel, containerPort: 3024 }
- { name: grpc, containerPort: 3025 }
env:
- name: MEZITE_DB_PASSWORD
valueFrom:
secretKeyRef: { name: mezite, key: db-password }
- name: MEZITE_CA_KEY_PASSPHRASE
valueFrom:
secretKeyRef: { name: mezite, key: ca-key-passphrase }
- name: MEZITE_RECORDING_ENC_KEY
valueFrom:
secretKeyRef: { name: mezite, key: recording-enc-key, optional: true }
- name: MEZITE_AUDIT_HMAC_KEY
valueFrom:
secretKeyRef: { name: mezite, key: audit-hmac-key, optional: true }
- name: MEZITE_PROXY_PUBLIC_ADDR
value: mezite.example.com:443
livenessProbe:
httpGet: { path: /healthz, port: https, scheme: HTTPS }
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
readinessProbe:
httpGet: { path: /readyz, port: https, scheme: HTTPS }
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: "1", memory: 512Mi }
volumeMounts:
- { name: config, mountPath: /etc/mezite, readOnly: true }
- { name: data, mountPath: /var/lib/mezite }
volumes:
- name: config
configMap: { name: mezite }
- name: data
persistentVolumeClaim: { claimName: mezhub-data }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mezhub-data
namespace: mezite
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: mezite
namespace: mezite
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: mezite
ports:
- { name: https, port: 3080, targetPort: https }
- { name: ssh, port: 3023, targetPort: ssh }
- { name: tunnel, port: 3024, targetPort: tunnel }
- { name: grpc, port: 3025, targetPort: grpc } kubectl apply -f mezite-config.yaml
kubectl apply -f mezhub.yaml
kubectl -n mezite rollout status deployment/mezhub Database migrations run automatically on startup — there is no separate migration step or job to schedule.
PostgreSQL Setup
Nothing above bundles PostgreSQL — point the database block at
an existing instance. For tests or trial installs, the Bitnami PostgreSQL chart
works well:
helm install pg bitnami/postgresql \
--namespace mezite \
--set auth.username=mezite \
--set auth.password=secret \
--set auth.database=mezite Scaling
Keep replicas: 1. Each agent holds its reverse tunnel open to
exactly one mezhub Pod, and there is no proxy-to-proxy
forwarding — a Pod can only reach nodes whose tunnels terminate on itself.
With more than one replica behind the Service, client connections
round-robin onto Pods that hold no tunnel for the requested node and fail.
Scale vertically by raising the Pod's CPU and memory limits.
Two further constraints reinforce this today: the
ReadWriteOnce volume cannot be mounted by two Pods on most storage
classes, and agents reconnect through the Service to whichever Pod answers next,
so a rollout moves tunnels rather than sharing them. Make sure your load balancer
allows long-lived TCP connections on port 3024 (the agent tunnel port) so those
tunnels are not cut mid-session.
Ingress and Single-Port (ALPN) Routing
The recommended public shape for a Kubernetes deployment is the same
shape that managed Mezite uses: one external port (:443)
carrying HTTPS, SSH, and the agent tunnel, demultiplexed by TLS ALPN.
This is what the proxy's proxy.single_port mode is built
for, and it is the only shape that reliably traverses corporate
firewalls and L4 load balancers.
Two viable topologies in Kubernetes:
- L4 Service of type
LoadBalancer→mezhubPod withproxy.single_port=true. The LB does no L7 work; TLS is terminated bymezhubitself, which means agent reverse tunnels and SSH traffic remain end-to-end TLS to the Pod. This is the simplest shape and the easiest one to reason about under cert rotation. - Ingress / Gateway with TLS passthrough on the
public listener, routing to the
mezhubService's:3080port. Most Ingress controllers (nginx, HAProxy, Traefik) support TLS passthrough; do not use TLS termination at the Ingress here — terminating TLS at the Ingress would break the ALPN demultiplexing the proxy relies on for SSH and tunnel routing.
Set proxy.public_addr to the external hostname
(mezite.example.com:443) so WebAuthn and the cluster's
enrollment URL match what clients connect to. See
Configuration for the full reference —
single-port mode is proxy.single_port in the YAML file, or
MEZITE_SINGLE_PORT=true in the environment.
Availability
Because tunnel routing is per-Pod, as described under Scaling above, availability comes from fast recovery of a single Pod rather than from running several. What you can do today:
- Use an external, managed PostgreSQL with its own replica. The database is the only durable state, and it is the tier that benefits most from redundancy.
-
Keep the deployment strategy at
Recreate. A rolling update cannot work against theReadWriteOncedata volume: the new Pod would wait forever to mount a volume the old Pod still holds, whilemaxUnavailable: 0prevents the old Pod from going away. Accept the brief outage — agents reconnect to the new Pod once it is ready. If you move the data volume to storage that genuinely supports concurrent mounts, revisit this alongside the single-instance limit above. -
There is no separate migration step.
mezhubapplies pending migrations at startup under a PostgreSQL advisory lock, so a Pod restarting during a rollout cannot corrupt the schema. -
Both AWS IAM joins and bootstrap-token joins (
mezctl tokens create) survive a Pod restart: the IAM join challenge lives inside a single gRPC stream and is never cached across connections.
NetworkPolicies
For a hardened cluster, restrict mezhub Pod ingress to
the listeners you expose externally, and restrict its egress to the
database and (when used) AWS KMS. The minimum useful
NetworkPolicy for the proxy looks like:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mezhub
namespace: mezite
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: mezite
policyTypes: [Ingress, Egress]
ingress:
# External traffic comes from the Ingress / LoadBalancer; restrict to
# the listeners you actually expose.
- ports:
- protocol: TCP
port: 3080 # HTTPS (or 443 in single-port mode)
- protocol: TCP
port: 3025 # gRPC Auth (mezctl admin + agent registration)
- protocol: TCP
port: 3023 # SSH
- protocol: TCP
port: 3024 # agent tunnel
egress:
# Database (in-cluster PostgreSQL example — see below for external PG).
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
ports:
- protocol: TCP
port: 5432
# DNS
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
# AWS KMS (only needed when kms.enabled=true). Allow egress to AWS API
# endpoints; in practice this is "443 to the internet" or to a VPC
# endpoint for kms.
- ports:
- protocol: TCP
port: 443
The example above assumes Postgres runs in-cluster behind a
podSelector. For externally-managed PostgreSQL (RDS,
Cloud SQL, a self-managed VM, or a PG operator in a different
namespace), replace the database egress rule with an
ipBlock for the database's address (a single
/32 or the VPC subnet's CIDR) or a
namespaceSelector + podSelector targeting
the operator's namespace. For example:
egress:
# External Postgres (e.g. RDS endpoint resolved to a private subnet).
- to:
- ipBlock:
cidr: 10.0.0.0/16 # your DB subnet (use a /32 for a single host)
ports:
- protocol: TCP
port: 5432
Multi-tenant managed deployments add a per-tenant
tenant-isolation NetworkPolicy on top of the above to
stop cross-tenant TCP. See the
Troubleshooting guide for how to
diagnose policy denials.