Kubernetes Deployment Guide
April 10, 2026 · View on GitHub
Note: Kubernetes deployment is currently in testing and development. We welcome feedback to help improve the manifests and deployment experience.
This guide uses the static manifests in deploy/k8s/. Download them, create a Secret with your config.yaml, then apply.
Prerequisites
- Kubernetes cluster (v1.20+)
kubectl(includes kustomize support forkubectl apply -k)
Upgrading to 1.3
If you are upgrading from 1.2.x or earlier, read and follow
docs/MIGRATION_1.3.md before applying the manifests. New installations can
proceed with the Quick Start below.
Image Selection
The base Kubernetes manifest uses kustomize images transform to set the container image tag. The default kustomization.yaml pins to a specific stable release tag (e.g., 1.3.3). Edit kustomization.yaml to change the tag for your target release.
Setting a specific image tag
Edit deploy/k8s/kustomization.yaml to set a specific tag:
images:
- name: ghcr.io/jeremiah-k/mmrelay
newTag: 1.3.3 # Change to your desired version
Alternatively, use kustomize edit from the command line:
kustomize edit set image ghcr.io/jeremiah-k/mmrelay:<tag>
Pinning digests for production
For production deployments, use the digest overlay to pin a specific image digest. This provides immutable image references.
-
Find the digest for your desired tag:
skopeo inspect docker://ghcr.io/jeremiah-k/mmrelay:<tag> -
Update
deploy/k8s/overlays/digest/kustomization.yamlwith the digest:images: - name: ghcr.io/jeremiah-k/mmrelay digest: sha256:abc123... # Replace with actual digest -
Apply the overlay:
kubectl apply -k ./deploy/k8s/overlays/digest
Tags and digests are listed on the GitHub Packages page: https://github.com/jeremiah-k/meshtastic-matrix-relay/pkgs/container/mmrelay
Quick Start (new install, static manifests)
# Create a project directory and change into it
mkdir -p mmrelay
cd mmrelay
# Download manifests from the main branch
BASE_URL="https://raw.githubusercontent.com/jeremiah-k/meshtastic-matrix-relay/main/deploy/k8s"
mkdir -p ./deploy/k8s/overlays/digest
curl -fLo ./deploy/k8s/pvc.yaml "${BASE_URL}/pvc.yaml"
curl -fLo ./deploy/k8s/networkpolicy.yaml "${BASE_URL}/networkpolicy.yaml"
curl -fLo ./deploy/k8s/deployment.yaml "${BASE_URL}/deployment.yaml"
curl -fLo ./deploy/k8s/kustomization.yaml "${BASE_URL}/kustomization.yaml"
curl -fLo ./deploy/k8s/overlays/digest/kustomization.yaml "${BASE_URL}/overlays/digest/kustomization.yaml"
# Set a specific image tag (recommended; avoid floating latest in production/tests)
${EDITOR:-vi} ./deploy/k8s/kustomization.yaml
# Set newTag to 1.3.3 (or your target release tag)
# If you change the namespace, update the --namespace/-n flags below
# Ensure the namespace exists
kubectl create namespace mmrelay --dry-run=client -o yaml | kubectl apply -f -
# Create config.yaml from the project sample
curl -Lo ./config.yaml https://raw.githubusercontent.com/jeremiah-k/meshtastic-matrix-relay/main/src/mmrelay/tools/sample_config.yaml
${EDITOR:-vi} ./config.yaml
# The default manifest sets MMRELAY_HOME=/data, so credentials,
# database, logs, and E2EE store will all persist on the PVC.
# All runtime state lives under /data inside the container.
# No legacy environment variables or CLI flags are required for container deployments.
# Create a Matrix auth secret (environment-based auth bootstrap)
# Set credentials (bash required for interactive prompts)
read -p "Matrix homeserver URL (e.g., https://matrix.example.org): " HOMESERVER
read -p "Matrix bot user ID (e.g., @bot:example.org): " BOT_USER_ID
read -s -p "Matrix password: " PASSWORD; echo
kubectl create secret generic mmrelay-matrix-auth \
--from-literal=MMRELAY_MATRIX_HOMESERVER="$HOMESERVER" \
--from-literal=MMRELAY_MATRIX_BOT_USER_ID="$BOT_USER_ID" \
--from-literal=MMRELAY_MATRIX_PASSWORD="$PASSWORD" \
--namespace mmrelay
# NOTE: This bootstrap secret should be created once per fresh namespace/PVC.
# Recreating namespace/PVC repeatedly forces new Matrix logins and may trigger
# homeserver rate limits.
# Store config.yaml in a Kubernetes Secret
kubectl create secret generic mmrelay-config \
--from-file=config.yaml=./config.yaml \
--namespace mmrelay
# Apply manifests
kubectl apply -k ./deploy/k8s
# Check status
kubectl get pods -n mmrelay -l app=mmrelay
kubectl logs -n mmrelay -f deployment/mmrelay
Secrets and configuration
The deployment mounts a Secret named mmrelay-config with one key:
config.yaml
Authentication secrets are provided separately using environment variables
via the optional mmrelay-matrix-auth Secret (see example above). On first
startup, MMRelay will log in with the provided credentials and create
/data/matrix/credentials.json on the persistent volume.
This keeps sensitive data out of the manifests so you can publish the manifests without exposing secrets. If you use an external secrets manager (External Secrets, Sealed Secrets, Vault, etc.), create the same Secret name/keys.
Config injection options
Primary recommendation: use a config.yaml file and create mmrelay-config as a Secret.
This keeps operator workflow simple and consistent with the rest of the docs.
MMRelay supports two patterns for injecting config.yaml:
Pattern A (default): Secret
The default manifest uses a Secret to mount config.yaml:
kubectl create secret generic mmrelay-config \
--from-file=config.yaml=./config.yaml \
--namespace mmrelay
This is the recommended approach because:
- Secrets can integrate with external secret managers (External Secrets, Sealed Secrets, Vault)
- Secrets are not logged or tracked in clear text by default
- Supports rotation via external secret management systems
Pattern B (optional, advanced): ConfigMap
If your organization requires ConfigMaps (for non-sensitive config), you can switch to this mode. For most users, keep the default Secret-based flow to avoid extra configuration complexity.
-
Create the ConfigMap:
kubectl create configmap mmrelay-config \ --from-file=config.yaml=./config.yaml \ --namespace mmrelay -
Edit
deployment.yamlto replace the Secret volume with a ConfigMap volume:-
In the
volumessection, change theconfig-sourcevolume fromsecrettoconfigMap:- name: config-source configMap: name: mmrelay-config items: - key: config.yaml path: config.yaml -
The init container mounts
config-sourceanddata; the main container mountsdataandtmp
-
Important: Only enable one pattern at a time (Secret OR ConfigMap), not both.
Credentials injection
MMRelay includes a recommended pattern for injecting credentials.json from a Secret. This approach is safer than editing files inside running containers and enables credential rotation.
Create the credentials Secret
Generate credentials.json locally (or copy from an existing installation):
# Option 1: Create credentials.json locally (run locally with same config.yaml)
mmrelay auth login
# Option 2: Copy existing credentials from another deployment
# Ensure the Matrix homeserver and bot user match your config.yaml
cp ~/.mmrelay/matrix/credentials.json ./credentials.json
Create the Secret:
kubectl create secret generic mmrelay-credentials \
--from-file=credentials.json=./credentials.json \
--namespace mmrelay
Enable credentials Secret in deployment
-
Add a credentials Secret volume to
deployment.yaml:-
In the
volumessection, add:- name: credentials secret: secretName: mmrelay-credentials items: - key: credentials.json path: credentials.json -
In
spec.template.spec.containers[0].volumeMounts, add:- name: credentials mountPath: /data/matrix/credentials.json subPath: credentials.json readOnly: true
-
-
Delete the optional
mmrelay-matrix-authSecret (if used):kubectl delete secret mmrelay-matrix-auth -n mmrelay -
Restart the pod:
kubectl delete pod -n mmrelay -l app=mmrelay
The pod will start using the mounted credentials.json instead of bootstrapping from environment variables.
Rotate credentials
To rotate credentials:
-
Generate new credentials.json locally:
mmrelay auth login # This overwrites existing credentials.json -
Update the Secret:
kubectl create secret generic mmrelay-credentials \ --from-file=credentials.json=./credentials.json \ --dry-run=client -o yaml \ --namespace mmrelay | kubectl apply -f - -
Restart the pod:
kubectl delete pod -n mmrelay -l app=mmrelay
The new credentials will be loaded on the next startup.
Alternative: Environment-based auth (bootstrap mode)
The default deployment includes an optional mmrelay-matrix-auth Secret for bootstrap mode. On first startup, MMRelay:
- Reads Matrix credentials from environment variables
- Logs into Matrix
- Creates
/data/matrix/credentials.jsonon the PVC - On subsequent restarts, uses the existing credentials.json
This is useful for:
- Initial deployment when you don't have credentials.json yet
- Environments where Secret rotation is handled externally
Note: Once credentials.json exists, the environment variables are no longer needed.
Storage
The deployment uses /data as the base directory for all persistent data:
- Credentials:
/data/matrix/credentials.json(auto-created on first login) - Logs:
/data/logs/ - Database:
/data/database/meshtastic.sqlite - E2EE store:
/data/matrix/store/(if encryption is enabled) - Plugins (custom):
/data/plugins/custom/ - Plugins (community):
/data/plugins/community/
This is configured in deployment.yaml via MMRELAY_HOME=/data and the PVC mount. All data persists across pod restarts.
./deploy/k8s/pvc.yaml uses the cluster default StorageClass. If your cluster requires a specific StorageClass, add storageClassName there.
Backup, restore, and disaster recovery
Runtime state location
All MMRelay runtime state lives under /data inside the container:
/data/
├── matrix/
│ ├── credentials.json # Matrix authentication credentials
│ └── store/ # E2EE encryption keys (if enabled)
├── database/
│ └── meshtastic.sqlite # SQLite database (nodes, messages, state)
├── logs/ # Application logs
└── plugins/ # Custom and community plugins
├── custom/
└── community/
The PVC is the authoritative source for all persistent data. Backing up the PVC preserves your complete MMRelay state.
Backup
Method 1: PVC snapshot (recommended)
Most Kubernetes storage providers support volume snapshots:
# Create a snapshot of the mmrelay-data PVC
kubectl create volumesnapshot mmrelay-data-backup-$(date +%Y%m%d) \
--source=persistentvolumeclaim/mmrelay-data \
--namespace mmrelay
Check your cloud provider's documentation for:
- Snapshot creation limits (frequency, retention)
- Snapshot-to-PVC restoration procedure
- Cost implications of snapshots
Method 2: rsync backup
Create a backup to local storage:
# Get the pod name
POD_NAME=$(kubectl get pods -n mmrelay -l app=mmrelay -o jsonpath='{.items[0].metadata.name}')
# Copy /data to local directory
kubectl exec -n mmrelay $POD_NAME -- tar czf - /data > mmrelay-backup-$(date +%Y%m%d).tar.gz
For larger deployments, use rsync:
# Create a temporary pod with the PVC
kubectl run backup-pod \
--image=busybox:1.36 \
--overrides='{
"spec": {
"containers": [{
"name": "backup",
"image": "busybox:1.36",
"command": ["sleep", "3600"],
"volumeMounts": [{
"name": "data",
"mountPath": "/data"
}]
}],
"volumes": [{
"name": "data",
"persistentVolumeClaim": {
"claimName": "mmrelay-data"
}
}]
}
}' \
--namespace mmrelay
# Copy data from the temporary pod
kubectl cp -n mmrelay backup-pod:/data ./mmrelay-backup
# Clean up the temporary pod
kubectl delete pod backup-pod -n mmrelay
Restore
From PVC snapshot
Restore a snapshot (procedure varies by storage provider):
# Example: Create a new PVC from a snapshot
kubectl apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mmrelay-data-restored
namespace: mmrelay
spec:
accessModes:
- ReadWriteOnce
storageClassName: <your-storage-class>
resources:
requests:
storage: 1Gi
dataSource:
name: mmrelay-data-backup-20250101
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
EOF
# Update deployment to use the restored PVC
kubectl set volume deployment/mmrelay -n mmrelay \
--name=data \
--overwrite \
--pvc-name=mmrelay-data-restored
From backup archive
# Stop the pod
kubectl scale deployment mmrelay -n mmrelay --replicas=0
# Create a temporary pod with the PVC
kubectl run restore-pod \
--image=busybox:1.36 \
--overrides='{
"spec": {
"containers": [{
"name": "restore",
"image": "busybox:1.36",
"command": ["sleep", "3600"],
"volumeMounts": [{
"name": "data",
"mountPath": "/data"
}]
}],
"volumes": [{
"name": "data",
"persistentVolumeClaim": {
"claimName": "mmrelay-data"
}
}]
}
}' \
--namespace mmrelay
# Copy the backup to the PVC
kubectl cp ./mmrelay-backup -n mmrelay restore-pod:/data/
# Clean up the temporary pod
kubectl delete pod restore-pod -n mmrelay
# Start the pod
kubectl scale deployment mmrelay -n mmrelay --replicas=1
Migration
The mmrelay migrate command handles data migrations between versions:
# Dry-run: Preview changes without making them
kubectl exec -n mmrelay <pod-name> -- mmrelay migrate --dry-run
# Perform migration
kubectl exec -n mmrelay <pod-name> -- mmrelay migrate
# Force migration (if files already exist in target)
kubectl exec -n mmrelay <pod-name> -- mmrelay migrate --force
The migration command is designed to be idempotent and safe. It:
- Detects legacy directory structures
- Moves files to the unified
/datalayout - Creates necessary directories
- Preserves your existing data
For detailed migration instructions, see the Migration Guide for v1.3.
First-boot health expectations
After upgrades, pods can remain unready/unhealthy until credentials/bootstrap state exists or migration is completed.
Recommended sequence:
# Preview migration
kubectl exec -n mmrelay <pod-name> -- mmrelay migrate --dry-run
# Apply migration
kubectl exec -n mmrelay <pod-name> -- mmrelay migrate
# Verify
kubectl exec -n mmrelay <pod-name> -- mmrelay verify-migration
kubectl exec -n mmrelay <pod-name> -- mmrelay doctor
Deprecation timeline: legacy credential/location fallbacks are supported in v1.3 and planned for removal in v1.4.
Disaster recovery checklist
-
Prevention:
- Enable PVC snapshots (if supported by your storage provider)
- Set up regular backup schedules (cron, Velero, etc.)
- Test backup restoration procedures regularly
-
Detection:
- Monitor pod health (liveness/readiness probes)
- Check PVC status (
kubectl get pvc -n mmrelay) - Verify disk space usage (
kubectl exec -n mmrelay <pod> -- df -h /data)
-
Recovery:
- Restore from the most recent backup
- Verify the pod starts successfully
- Run
mmrelay doctorto validate the installation - Check logs for any errors after restoration
Operator safety notes
Safe to delete
The following resources can be safely deleted and will be recreated automatically:
- Pods: Deleting a pod triggers Kubernetes to create a replacement pod.
- Deployments: Deleting the deployment requires you to re-apply the manifest.
- Secrets/ConfigMaps: Updating them does not automatically rewrite
/data/config.yamlonce it exists on PVC; restart workflows should account for init-copy behavior.
# Safe: Delete a pod (will be recreated)
kubectl delete pod -n mmrelay <pod-name>
# Safe: Force a pod restart via deployment
kubectl rollout restart deployment mmrelay -n mmrelay
Must never delete
The following resources contain persistent data and must never be deleted:
- PersistentVolumeClaim (PVC):
mmrelay-data- Contains all runtime state (credentials, database, logs, E2EE keys, plugins)
- Deleting the PVC results in permanent data loss
# NEVER run this command
kubectl delete pvc mmrelay-data -n mmrelay # DANGEROUS - permanent data loss
If you need to reset the PVC:
- Scale down the deployment (
kubectl scale deployment mmrelay -n mmrelay --replicas=0) - Delete the PVC (
kubectl delete pvc mmrelay-data -n mmrelay) - only if you have a backup - Re-apply the PVC manifest
- Scale up the deployment
Auto-recreated
The following paths recreate themselves automatically on startup:
/tmp/mmrelay-ready: Ready file (auto-created, checked by probes)- Caches: Temporary data cached in memory or temporary files
- Logs: New log files are created on startup (old logs are retained in
/data/logs/)
These are not persistent and should not be backed up.
Authoritative data
The PVC is the single source of truth for persistent data:
/data(PVC): Authoritative - persistent, backed up/data/config.yaml: Persistent after first startup - copied by the init container from Secret/ConfigMap onto the PVC; subsequent pod restarts use the PVC copy/tmp/mmrelay-ready: Not persistent - recreated on each pod start/tmp: Not persistent - temporary storage
When debugging or troubleshooting, always verify the contents of /data on the PVC.
Operational model
Health probes
MMRelay uses Kubernetes startup, readiness, and liveness probes to ensure the pod is operating correctly:
Readiness probe (period: 10s, timeout: 2s, failureThreshold: 3):
- Checks if the ready file exists at
/tmp/mmrelay-ready - Cheap and stable check that determines service routing
- The pod is marked "Ready" when the ready file exists
- Traffic is only sent to ready pods
Startup probe (period: 5s, timeout: 2s, failureThreshold: 60):
- Also checks for the ready file at `/tmp/mmrelay-ready$
- \text{Allows} \text{up} \text{to} 5 \text{minutes} \text{for} \text{initialization} (60 \text{failures} \times 5\text{s} = 300\text{s})
- \text{Prevents} \text{the} \text{liveness} \text{probe} \text{from} \text{killing} \text{the} \text{pod} \text{during} \text{slow} \text{startup}
- \text{Once} \text{the} \text{startup} \text{probe} \text{succeeds}, \text{the} \text{liveness} \text{probe} \text{takes} \text{over}
\text{Liveness} \text{probe} (\text{period}: 60\text{s}, \text{timeout}: 20\text{s}, \text{failureThreshold}: 3):
- \text{Checks} \text{that} \text{the} \text{ready} \text{file} \text{at} $/tmp/mmrelay-ready` has been modified within the last 2 minutes
- Verifies the application is still actively updating the ready file (not frozen/deadlocked)
- If the probe fails repeatedly, Kubernetes will restart the pod
- The longer period and timeout reduce false positives for transient issues
The ready file check verifies:
- The application process is running and responsive
- The periodic ready file updates are occurring (updated every loop iteration)
Why this split?
- Readiness determines service routing; it should be cheap and stable (ready-file check)
- StartupProbe prevents crashloops on slow initialization by disabling liveness checks during startup
- Liveness can be deeper and slower because it runs infrequently and only after startup succeeds
Troubleshooting probe failures
If a pod is not ready or keeps restarting:
-
Check the pod logs:
kubectl logs -n mmrelay <pod-name> -
Verify the ready file exists:
kubectl exec -n mmrelay <pod-name> -- ls -l /tmp/mmrelay-ready -
Run doctor inside the pod:
kubectl exec -n mmrelay <pod-name> -- mmrelay doctor -
Verify the config Secret is mounted:
kubectl exec -n mmrelay <pod-name> -- cat /data/config.yaml -
Check the persistent volume claim status:
kubectl get pvc -n mmrelay mmrelay-data
Graceful shutdown
MMRelay implements safe shutdown via a Kubernetes preStop lifecycle hook:
# Send SIGTERM to allow graceful shutdown
sleep 5 || true
This gives MMRelay time to:
- Flush the database to disk
- Store any pending state
- Disconnect cleanly from the mesh network
The deployment sets terminationGracePeriodSeconds: 30, which allows the preStop hook and process cleanup to complete before Kubernetes sends SIGKILL.
Data directory ownership
Even with fsGroup: 1000 set, some CSI drivers mount volumes owned by root. To ensure MMRelay can write to /data, an initContainer runs before the main container starts:
initContainers:
- name: init-mmrelay
image: busybox:1.36
command:
- sh
- -c
- |
if [ ! -f /data/config.yaml ]; then cp /config-source/config.yaml /data/config.yaml; fi
mkdir -p /data/matrix
chown -R 1000:1000 /data
This guarantees:
- MMRelay (running as UID/GID 1000) can write credentials, database, plugins, and logs
- Works across different storage backends (NFS, Ceph, local, etc.)
The initContainer runs as root (runAsUser: 0) to modify ownership, then the main container runs as the non-root user (1000:1000).
Verification
After deployment, verify your configuration:
# Get the pod name
POD_NAME=$(kubectl get pods -n mmrelay -l app=mmrelay -o jsonpath='{.items[0].metadata.name}')
# Run diagnostics
kubectl exec -n mmrelay $POD_NAME -- mmrelay doctor
# Verify paths
kubectl exec -n mmrelay $POD_NAME -- mmrelay paths
Expected output (summary):
- HOME is `/data`
- No legacy environment variables (MMRELAY_CREDENTIALS_PATH, MMRELAY_BASE_DIR, MMRELAY_DATA_DIR) are set
- All runtime paths resolve under `/data`
Connection types
TCP (recommended)
No manifest changes required. Configure meshtastic.connection_type: tcp in config.yaml.
Serial
Serial requires host device access and node pinning. Start with the most restrictive settings and only escalate if needed.
-
Add the device mount to the container:
In
./deploy/k8s/deployment.yaml, add this entry underspec.template.spec.containers[0].volumeMounts:- name: serial-device mountPath: /dev/ttyUSB0 -
Add the hostPath volume:
In the same file, add this under
spec.template.spec.volumes:- name: serial-device hostPath: path: /dev/ttyUSB0 type: CharDevice -
Pin the pod to the node with the device:
Add this under
spec.template.spec:nodeSelector: kubernetes.io/hostname: node-with-device -
Add pod-level security context for supplemental groups:
Add this under
spec.template.spec:securityContext: supplementalGroups: - 20 # device group (often dialout) -
If
supplementalGroupsis insufficient, try adding capabilities before falling back to root. KeepallowPrivilegeEscalation: falseand use the smallest capability set that works for your cluster policy:securityContext: allowPrivilegeEscalation: false capabilities: add: - DAC_OVERRIDE -
If capabilities still do not work, try running as root with
runAsUser: 0andrunAsGroup: 0:Warning: Running as root should only be used after supplemental groups and capabilities both fail. It is not recommended for production.
securityContext: runAsUser: 0 runAsGroup: 0 allowPrivilegeEscalation: false -
If you still get permission errors, use
privileged: trueas a last resort only.
BLE
BLE is difficult to run in Kubernetes. Use TCP or serial whenever possible. If you must use BLE, expect additional host access and security considerations:
- Host networking and node pinning are typically required for stable BLE access.
- You may need access to the host Bluetooth stack (BlueZ) via DBus and elevated permissions.
- Start with the least privilege that works; only use privileged mode as a last resort.
Because environments differ widely, treat BLE support in Kubernetes as experimental.
Notes
- Ready file: The ready file feature is enabled by default via
MMRELAY_READY_FILE=/tmp/mmrelay-readyin the deployment:- Readiness and startup probes check for the marker file at
/tmp/mmrelay-ready - Liveness probe verifies the ready file at
/tmp/mmrelay-readywas modified within the last 2 minutes - Heartbeat interval is configurable via
MMRELAY_READY_HEARTBEAT_SECONDS(default: 60s)
- Readiness and startup probes check for the marker file at
- NetworkPolicy: The default NetworkPolicy allows all egress; restrict CIDRs as needed for production. The default policy includes rules for both IPv4 (
0.0.0.0/0) and IPv6 (::/0) egress.