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 mezhub image
  • 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.

mezite-config.yaml yaml
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.

mezhub.yaml yaml
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 }
Apply bash
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:

Example: deploy PostgreSQL with Helm bash
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 LoadBalancermezhub Pod with proxy.single_port=true. The LB does no L7 work; TLS is terminated by mezhub itself, 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 mezhub Service's :3080 port. 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 the ReadWriteOnce data volume: the new Pod would wait forever to mount a volume the old Pod still holds, while maxUnavailable: 0 prevents 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. mezhub applies 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:

NetworkPolicy: minimum useful policy for mezhub yaml
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:

NetworkPolicy egress: external PostgreSQL via ipBlock yaml
  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.