This Kubernetes deployment serves two related goals:
- A working starting point. A complete, runnable OpenEMR environment on Kubernetes — usable on Kind for local development and adaptable for production deployments on EKS, GKE, AKS, or on-prem clusters.
- A reference for secure distributed deployments. The stack demonstrates the security primitives (mTLS with X509 client certificate verification, encrypted MariaDB replication, Redis Sentinel with mTLS) needed when OpenEMR components may be installed across trust boundaries — different clusters, different VPCs, or communicating over the public internet.
Which lens you read this through affects what's essential vs. defense-in-depth:
- Single-cluster production users (one EKS/GKE/AKS cluster on modern hyperscaler hardware): the cloud provider's transparent infrastructure encryption already covers wire confidentiality between nodes. The mTLS layers in this stack are then defense-in-depth and provide endpoint authentication; see "Security Architecture" for what each layer adds and "Connection Security" for downgrade paths.
- Distributed deployment users (components separated across networks or trust boundaries): the mTLS layers are load-bearing — they're the only thing protecting and authenticating connections that cross untrusted networks. Don't disable them.
This solution requires OpenEMR Docker 8.2.0 or higher. The flex Docker series is also supported for development purposes (change to openemr/openemr:flex in openemr/deployment.yaml, though startup will be significantly slower as each instance builds from source). While not a fully hardened production deployment, this provides a solid working foundation with mTLS encryption, Redis Sentinel failover, and multi-node support, and should open the door to a myriad of other Kubernetes-based solutions.
OpenEMR Kubernetes orchestration. Orchestration includes OpenEMR, MariaDB, Redis, and phpMyAdmin.
- OpenEMR - 3 deployment replications of OpenEMR are created. Replications can be increased/decreased. Ports for both http and https.
- MariaDB - 2 statefulset replications of MariaDB (1 primary/master with 1 replica/slave) are created. Replications can be increased/decreased which will increase/decrease number of replica/slaves. Connections use mTLS (mutual TLS / X509 client certificate verification) by default, including replication traffic. See Security Architecture → MariaDB connections for what this protects and Connection Security → MariaDB Connection Security for how to downgrade to TLS-only or plain TCP.
- Redis - Configured to support failover. There is 1 master and 2 slaves (no read access on slaves) for a statefulset and 3 sentinels for another statefulset. OpenEMR connects directly to Redis with mTLS (mutual TLS / X509 client certificate verification) by default. The primary/slaves and sentinels would require script changes if wish to increase/decrease replicates for these since these are hard-coded several places in the scripts. There are 3 users/passwords (
default,replication,admin) used in this redis scheme. All passwords are stored in theredis-credentialsKubernetes Secret (redis/secret.yaml) and should be changed for production use. Thedefaultis the typical worker/app/client user. See Security Architecture → Redis connections for details and Connection Security → Redis Connection Security for how to downgrade. - phpMyAdmin - There is 1 deployment instance of phpMyAdmin. Access is via
kubectl port-forwardonly (not exposed externally).
- Install Kind. Other Kubernetes distributions can be substituted by users familiar with their networking and storage models, but the instructions below assume Kind.
- For Kind, see below for instructions sets with 1 node or 4 nodes.
- 1 node:
kind create cluster --config kind-config-1-node.yaml
- 4 nodes (1 control-plane node and 3 worker nodes). Shared volumes use an in-cluster NFS provisioner (deployed by kub-up) so pods on different nodes can share ReadWriteMany volumes:
kind create cluster --config kind-config-4-nodes.yaml
- After you run the kub-up command below, here is a neat command to show which nodes the pods are in
kubectl get pod -o wide
- After you run the kub-up command below, here is a neat command to show which nodes the pods are in
- 1 node:
- For Kind, see below for instructions sets with 1 node or 4 nodes.
- To start OpenEMR orchestration:
bash kub-up
- Can see overall progress with following command:
kubectl get all
- It will look something like this when completed:
NAME READY STATUS RESTARTS AGE pod/mysql-sts-0 1/1 Running 0 111s pod/mysql-sts-1 1/1 Running 0 91s pod/nfs-provisioner-77f85859c4-xxxxx 1/1 Running 0 3m pod/openemr-7889cf48d8-9jdfl 1/1 Running 0 111s pod/openemr-7889cf48d8-qphrw 1/1 Running 0 111s pod/openemr-7889cf48d8-zlx9f 1/1 Running 0 111s pod/phpmyadmin-f4d9bfc69-rx82d 1/1 Running 0 111s pod/redis-0 1/1 Running 0 111s pod/redis-1 1/1 Running 0 77s pod/redis-2 1/1 Running 0 55s pod/sentinel-0 1/1 Running 0 111s pod/sentinel-1 1/1 Running 0 34s pod/sentinel-2 1/1 Running 0 30s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 3m40s service/mysql ClusterIP None <none> 3306/TCP 111s service/nfs-provisioner ClusterIP 10.96.1.73 <none> 2049/TCP,2049/UDP,... 3m service/openemr NodePort 10.96.6.51 <none> 8080:30080/TCP,8090:30443/TCP 111s service/phpmyadmin ClusterIP 10.96.64.163 <none> 8081/TCP,8091/TCP 111s service/redis ClusterIP None <none> 6379/TCP 111s service/sentinel ClusterIP None <none> 26379/TCP 111s NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/nfs-provisioner 1/1 1 1 3m deployment.apps/openemr 3/3 3 3 111s deployment.apps/phpmyadmin 1/1 1 1 111s NAME DESIRED CURRENT READY AGE replicaset.apps/nfs-provisioner-77f85859c4 1 1 1 3m replicaset.apps/openemr-7889cf48d8 3 3 3 111s replicaset.apps/phpmyadmin-f4d9bfc69 1 1 1 111s NAME READY AGE statefulset.apps/mysql-sts 2/2 111s statefulset.apps/redis 3/3 111s statefulset.apps/sentinel 3/3 111s
- It will look something like this when completed:
- Getting the url link to OpenEMR:
- With the provided Kind config files, OpenEMR is mapped to localhost:
http://localhost:8800orhttps://localhost:9800
- With the provided Kind config files, OpenEMR is mapped to localhost:
- Accessing phpMyAdmin:
- phpMyAdmin is not exposed externally for security. Access it via port-forward:
Then navigate to
kubectl port-forward service/phpmyadmin 8081:8081
http://localhost:8081. PressCtrl+Cto stop the port-forward when done.
- phpMyAdmin is not exposed externally for security. Access it via port-forward:
- Some cool replicas stuff with OpenEMR. The OpenEMR docker pods are run as a replica set (since it is set to 3 replicas in this OpenEMR deployment script). Gonna cover how to view the replica set and how to change the number of replicas on the fly in this step.
- First. lets list the replica set like this:
kubectl get rs
- It will look something like this (note OpenEMR has 3 desired and 3 current replicas going):
NAME DESIRED CURRENT READY AGE nfs-provisioner-77f85859c4 1 1 1 11m openemr-7889cf48d8 3 3 3 9m22s phpmyadmin-f4d9bfc69 1 1 1 9m22s
- It will look something like this (note OpenEMR has 3 desired and 3 current replicas going):
- Second, lets increase OpenEMR's replicas from 3 to 10 (ie. pretend in an environment where a huge number of OpenEMR users are using the system at the same time)
kubectl scale deployment.apps/openemr --replicas=10
- It will return the following:
deployment.apps/openemr scaled - Now, there are 10 replicas of OpenEMR instead of 3. Enter the
kubectl get rsandkubectl get podto see what happened.
- It will return the following:
- Third, lets decrease OpenEMR's replicas from 10 to 5 (ie. pretend in an environment where don't need to expend resources of offering 10 replicas, and can drop to 5 replicas)
kubectl scale deployment.apps/openemr --replicas=5
- It will return the following:
deployment.apps/openemr scaled - Now, there are 5 replicas of OpenEMR instead of 10. Enter the
kubectl get rsandkubectl get podto see what happened.
- It will return the following:
- This is just a quick overview of scaling. Note we just did manual scaling in the example above, but there are also options of automatic scaling for example depending on cpu use etc.
- First. lets list the replica set like this:
- Some cool replicas stuff with MariaDB. 2 statefulset replications of MariaDB (1 primary/master with 1 replica/slave) are created by default. The number of replicas can be increased or decreased.
- Increase replicas (after this command will have the 1 primary/master with 3 replicas/slaves).
kubectl scale sts mysql-sts --replicas=4
- Decrease replicas (after this command will have the 1 primary/master with 2 replicas/slaves).
kubectl scale sts mysql-sts --replicas=3
- Increase replicas (after this command will have the 1 primary/master with 3 replicas/slaves).
- Testing Redis Sentinel failover. Redis is configured with automatic failover via Sentinel. To test it:
- First, check which Redis pod is the current master:
kubectl exec redis-0 -- redis-cli --tls --cacert /certs/ca.crt --cert /certs/tls.crt --key /certs/tls.key --user admin -a adminpassword info replication | grep role
- Delete the master pod to simulate a failure:
kubectl delete pod redis-0
- Watch the sentinel logs to see the failover happen (~1 second):
kubectl logs sentinel-0 | grep failover - Verify a new master was promoted:
kubectl exec redis-1 -- redis-cli --tls --cacert /certs/ca.crt --cert /certs/tls.crt --key /certs/tls.key --user admin -a adminpassword info replication | grep role
- OpenEMR continues working throughout the failover — the Sentinel-based session handler automatically discovers the new master.
- First, check which Redis pod is the current master:
- To stop and remove OpenEMR orchestration (this will delete everything):
bash kub-down
- For Kind, also need to delete the cluster:
kind delete cluster
- For Kind, also need to delete the cluster:
The default stance assumes components may be deployed across trust boundaries — different clusters, different VPCs, or over the public internet. Where everything is deployed in a single cluster on a hyperscaler whose infrastructure provides transparent encryption (AWS Nitro, GCP), several of these layers become defense-in-depth rather than primary protection — but they remain useful and shouldn't be removed casually. The "Connection Security" section further down documents how to relax each layer when a more limited threat model permits.
OpenEMR ↔ MariaDB, phpMyAdmin ↔ MariaDB, and primary ↔ replica all use mTLS by default with X509 client certificate verification, plus MASTER_SSL_VERIFY_SERVER_CERT=1 on replica connections. All certificates are managed by cert-manager.
This provides three properties:
- Confidentiality of database traffic regardless of whether the underlying network is trusted.
- Mutual authentication: MariaDB refuses connections that don't present a valid client certificate (via
REQUIRE X509on the GRANT statements inmysql/configmap.yaml). Knowing the password is not sufficient. - Server identity verification: replicas refuse to follow a primary that doesn't present a cert chaining to the cluster CA, defeating man-in-the-middle replica swap attacks.
In a single-cluster deployment on AWS Nitro or GCP, the confidentiality property is redundant with hypervisor-level wire encryption, but the authentication and server-identity properties are not — they have no equivalent below the application layer.
OpenEMR ↔ Redis and OpenEMR ↔ Sentinel use mTLS via phpredis with Sentinel-based master discovery (SESSION_STORAGE_MODE=predis-sentinel). The tls-auth-clients yes setting in redis/configmap-main.yaml enforces client certificate presentation. Same three properties as MariaDB above.
The OpenEMR pods share three RWX volumes via the in-cluster NFS provisioner: websitevolume (/var/www/.../sites, contains patient documents), sslvolume (/etc/ssl), and letsencryptvolume (/etc/letsencrypt). NFS traffic between OpenEMR pods and the NFS provisioner pod is not encrypted by default — it uses NFSv4.1 over plain TCP with sec=sys.
mTLS sidecars and service meshes do not cover this path. NFS mounts are performed by the kubelet on the host network namespace, not by the pod, so application-layer mesh sidecars never see the traffic.
For deployments where this matters, see Production Hardening → CNI-level pod-to-pod encryption below for the standard fix.
The default deployment uses an in-cluster NFS provisioner (nfs/deployment.yaml) backed by a hostPath: /tmp/nfs-provisioner, and the MariaDB datadir uses the cluster's default StorageClass. Neither is encrypted at rest by default.
For dev/Kind use, this is fine — relying on the workstation's full-disk encryption (LUKS / FileVault / BitLocker) is the appropriate dev posture, and Kind has no native encryption knob.
For production deployments, see Production Hardening → Encryption at rest (storage layer) for storage-layer encryption options per cloud, and Production Hardening → MariaDB Transparent Data Encryption (TDE) for application-layer encryption inside the database.
All passwords (Redis, MariaDB replication, MariaDB root) are stored in Kubernetes Secret resources with default values suitable for development and testing. For production deployments, these Secret YAML files (redis/secret.yaml, mysql/replication-secret.yaml, mysql/secret.yaml, openemr/secret.yaml) should be replaced with secrets managed by an external secret manager (e.g., HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) using an operator like External Secrets Operator. The rest of the deployment (init containers, env var references, volume mounts) references Kubernetes Secrets by name and requires no changes regardless of how the secrets are provisioned.
network/policies.yaml is applied at the end of kub-up and restricts which pods can reach which services within the cluster. Combined with mTLS and REQUIRE X509, this means lateral movement from a compromised pod to an unrelated database connection is gated at both the network layer and the authentication layer.
The mTLS configuration above is the default. Both MariaDB and Redis connection security can be relaxed when a more limited threat model permits — for example, when running entirely within a single cluster on a hyperscaler whose infrastructure already provides wire encryption, or for development environments.
By default, MariaDB connections use mTLS (mutual TLS) with X509 client certificate verification for all connections (OpenEMR, phpMyAdmin, and replication). All certificates are managed by cert-manager. To downgrade the connection security:
mysql/configmap.yaml: In primary.sql, changeREQUIRE X509toREQUIRE SSL. In secondary.sql, remove theMASTER_SSL_CERTandMASTER_SSL_KEYlinesopenemr/deployment.yaml: ChangeFORCE_DATABASE_X509_CONNECTtoFORCE_DATABASE_SSL_CONNECTand remove thetls.crt(mysql-cert) andtls.key(mysql-key) items from themysql-openemr-client-certsvolumephpmyadmin/configmap.yaml: Comment out or remove thessl_certandssl_keylinesphpmyadmin/deployment.yaml: Remove thetls.crtandtls.keyitems from themysql-phpmyadmin-client-certsvolume
Perform all the TLS downgrade steps above, then additionally:
mysql/configmap.yaml: Removessl_ca,ssl_cert,ssl_keylines from both primary.cnf and replica.cnf. In primary.sql, changeREQUIRE SSLto nothing. In secondary.sql, removeMASTER_SSL_CA,MASTER_SSL, andMASTER_SSL_VERIFY_SERVER_CERTlinesopenemr/deployment.yaml: Remove theFORCE_DATABASE_SSL_CONNECTenvironment variable and remove the entiremysql-openemr-client-certsvolume and volumeMountphpmyadmin/configmap.yaml: Setssltofalse, removessl_ca, and removessl_verifyphpmyadmin/deployment.yaml: Remove the entiremysql-phpmyadmin-client-certsvolume and volumeMountcerts/mysql.yaml,certs/mysql-replication.yaml,certs/mysql-openemr-client.yaml,certs/mysql-phpmyadmin-client.yaml: These cert-manager Certificate resources can be removed entirelykub-upandkub-down(and.batvariants): Remove the mysql cert references
By default, Redis connections use mTLS (mutual TLS) with X509 client certificate verification. OpenEMR uses phpredis with Sentinel discovery for automatic failover (SESSION_STORAGE_MODE=predis-sentinel). All certificates are managed by cert-manager. To downgrade the connection security:
redis/configmap-main.yaml: Changetls-auth-clients yestotls-auth-clients noredis/statefulset-redis.yaml: ChangeREDISX509=truetoREDISX509=falseredis/statefulset-sentinel.yaml: ChangeREDISX509=truetoREDISX509=false(the sentinel config automatically setstls-auth-clientsbased on this value)openemr/deployment.yaml: Remove theREDIS_X509environment variable and remove the client cert/key items (redis-master-cert,redis-master-key,redis-sentinel-cert,redis-sentinel-key) from theredis-openemr-client-certsvolume
Perform all the TLS downgrade steps above, then additionally:
redis/configmap-main.yaml: Remove alltls-*lines, changeport 0toport 6379, and removetls-port 6379redis/statefulset-redis.yaml: Remove theTLSPARAMETERSvariable and its usage in redis-cli commands, and remove theredis-certsvolume and volumeMountredis/statefulset-sentinel.yaml: Remove theTLSPARAMETERSvariable and its usage in redis-cli commands, remove thesentinel-certsvolume and volumeMount, and remove alltls-*lines from the sentinel config generationopenemr/deployment.yaml: Remove theREDIS_TLS,REDIS_X509, andREDIS_TLS_CERT_KEY_PATHenvironment variables and remove the entireredis-openemr-client-certsvolume and volumeMountcerts/redis.yaml,certs/redis-openemr-client.yaml,certs/sentinel.yaml: These cert-manager Certificate resources can be removed entirelykub-upandkub-down(and.batvariants): Remove the redis/sentinel cert references
The stack ships with sensible defaults for development on Kind. For production deployments, the following swaps and additions are typical. None of them require changes to OpenEMR application code or to the application-level security model — they are storage and infrastructure adjustments around the existing manifests.
The simplest path to encryption-at-rest is to back the persistent volumes with an encrypted StorageClass at the cloud or storage-layer. Two PVCs matter:
- MariaDB
datadir(RWO) — setstorageClassNameon thevolumeClaimTemplatesentry inmysql/statefulset.yamlto your encrypted StorageClass. - NFS provisioner backing — replace the
hostPath: /tmp/nfs-provisionerinnfs/deployment.yamlwith a PVC backed by your encrypted StorageClass. The three RWX volumes (websitevolume,sslvolume,letsencryptvolume) inherit encryption from this single backing disk; no changes to OpenEMR manifests are needed.
| Cloud / Environment | StorageClass | Key management |
|---|---|---|
| AWS / EKS | gp3 with encrypted: "true" |
AWS KMS (CMK or AWS-managed) |
| GCP / GKE | pd-balanced or pd-ssd with disk-encryption-kms-key |
Cloud KMS / CMEK |
| Azure / AKS | managed-csi with diskEncryptionSetID |
Azure Disk Encryption Set |
| On-prem (self-hosted) | Longhorn with encrypted: "true" |
dm-crypt key in Kubernetes Secret |
| On-prem (Ceph) | Rook/Ceph with encrypted: "true" |
dm-crypt or Vault |
Storage-layer encryption protects against stolen disks, decommissioned media, snapshot exfiltration, and backup-tape theft. It does not protect against attackers who have access to the running cluster (pods see plaintext through the mounted filesystem).
For defense-in-depth beyond storage-layer encryption, MariaDB's built-in file_key_management plugin encrypts InnoDB tablespaces, redo logs, binary logs, and temp files at the application layer. The plugin ships in the stock mariadb:11.8.6 image used by this stack — no custom image is required.
Enabling TDE involves:
- A new Secret containing the keyfile (one or more lines of
<key-id>;<32 hex chars = 16-byte AES key>), optionally itself encrypted with a master password supplied viafile_key_management_filekey. - A volume mount on the MariaDB container at
/etc/mysql/encryption/, sourcing the secret withdefaultMode: 0400. - Configuration additions to both
primary.cnfandreplica.cnfinmysql/configmap.yaml:plugin-load-add=file_key_management file_key_management_filename=/etc/mysql/encryption/keyfile file_key_management_encryption_algorithm=AES_CTR innodb_encrypt_tables=ON innodb_encrypt_log=ON innodb_encrypt_temporary_tables=ON innodb_encryption_threads=4 encrypt-tmp-disk-tables=1 encrypt-tmp-files=1 encrypt-binlog=1 aria-encrypt-tables=1
Enable TDE at first boot so the system tablespace is born encrypted. Existing tables are encrypted in the background by innodb_encryption_threads. Both primary and replica pods consume the same Secret and ConfigMap, so the keyfile is automatically consistent across replication.
Trust boundary: TDE keys live in a Kubernetes Secret, so any principal with kubectl get secret access in the namespace can read both the keys and the data. TDE primarily protects against PV theft, snapshot exfiltration, and decommissioned-disk recovery — not in-cluster attackers. For a stricter trust boundary, replace file_key_management with the HashiCorp Vault or AWS KMS key-management plugins, which require a custom MariaDB image plus pod authentication to the external KMS.
Key rotation with file_key_management is manual: append a new key ID to the keyfile, increment innodb_encryption_rotate_key_age, and restart. Vault/KMS plugins automate this.
Backups: if mariabackup is added to the deployment, it must have access to the same keyfile to read encrypted tablespaces.
For the NFS shared-volume in-transit gap (and for any other pod-to-pod traffic outside the application-mTLS layers), the standard answer is CNI-level transparent encryption. This wraps all node-to-node pod traffic regardless of workload, including NFS, iSCSI, and the kubelet-initiated paths that service meshes can't reach.
| Environment | Approach |
|---|---|
| AWS / EKS on Nitro instances | Hyperscaler-native: same-VPC traffic between Nitro hypervisors is encrypted at the NIC level by AWS, transparent to pods. Typically sufficient for HIPAA encryption-in-transit attestation. |
| GCP / GKE | Same property — Google encrypts VM-to-VM traffic within a VPC at their network fabric layer. |
| Azure / AKS | More variable depending on VM SKU and region; check the specific SKU. Add CNI WireGuard if uncertain. |
| Any cluster (portable) | Cilium with encryption.enabled=true, encryption.type=wireguard, or Calico with WireGuard mode. Single config flag, encrypts all pod-to-pod traffic. |
| On-prem / air-gapped | CNI WireGuard (Cilium or Calico) is the standard. |
Service meshes (Istio, Linkerd) provide mTLS for application traffic but do not cover NFS or storage paths because those mounts happen at the kubelet, not in the pod. For the NFS gap specifically, CNI-level encryption is the right tool.
When adapting this stack for a production deployment, the typical hardening checklist is:
- Replace default passwords in all
*-credentialsand*-secret.yamlfiles (or wire up External Secrets Operator). - Set
storageClassNameonmysql/statefulset.yamlvolumeClaimTemplatesto an encrypted StorageClass. - Replace the NFS provisioner's
hostPathwith a PVC on an encrypted StorageClass. - Decide on TDE: enable at first boot if defense-in-depth is desired; document the key rotation runbook.
- Confirm pod-to-pod encryption: rely on hyperscaler-native (AWS Nitro / GCP) or enable CNI WireGuard.
- Review
network/policies.yamlfor your namespace topology. - Replace the self-signed cert-manager CA with a CA chained to your organization's PKI if cross-cluster trust is required.
- Determine ingress: the default NodePort exposure is for development; production typically fronts OpenEMR with an Ingress Controller and an external load balancer with proper TLS termination.