Managing environment variables on Kubernetes
Learn how to manage your environment variables when working with Kubernetes from configMaps to Secrets and external tools.
Written by
Technical Writer at Civo
Written by
Technical Writer at Civo
Have you ever wondered about the ways in which you can manage environment variables when working with Kubernetes? If so, you have come to the right place!
This article will take you through how to set up and use environment variables in your Kubernetes cluster. I will first create a cluster with Civo, demonstrate how to set environment variables from ConfigMaps, share environment variables securely using Secrets, and finally show you how to use a tool called Doppler to securely store your environment variables.
Prerequisites
- Verified Civo account
- Civo CLI installed
- kubectl installed
- Basic understanding of Kubernetes
Creating a Kubernetes cluster on Civo
You can run every command in this guide on any Kubernetes cluster, including minikube. If you want a real managed cluster to test against, the Civo CLI spins one up in well under two minutes:
civo kubernetes create env-demo \--size g4s.kube.small \--nodes 1 \--wait --save --switch
The --save --switch flags merge the new kubeconfig and make it your active context, so you can confirm you are talking to the cluster straight away:
kubectl get nodes
With a cluster in hand, we can start wiring configuration into pods.
Inline env and valueFrom
Start with the simplest case, values written straight into the pod spec. This is fine for genuinely static, non-sensitive values:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: webspec:replicas: 1selector:matchLabels:app: webtemplate:metadata:labels:app: webspec:containers:- name: webimage: nginx:1.27env:- name: HOST_NAMEvalue: "server1.example.com"- name: PORT_NUMBERvalue: "9001"EOF
Confirm the values landed inside the container:
kubectl exec deploy/web -- printenv | grep -E 'HOST_NAME|PORT_NUMBER'HOST_NAME=server1.example.comPORT_NUMBER=9001
Inline values do not scale, the same string gets copied into every Deployment that needs it.
More often we want to pull individual values from a ConfigMap or Secret, or from the pod's own metadata, with valueFrom:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: web-valuefromspec:replicas: 1selector:matchLabels:app: web-valuefromtemplate:metadata:labels:app: web-valuefromspec:containers:- name: webimage: nginx:1.27env:- name: LOG_LEVEL # from a ConfigMap keyvalueFrom:configMapKeyRef:name: app-configkey: log_level- name: DB_PASSWORD # from a Secret keyvalueFrom:secretKeyRef:name: db-credskey: PASSWORD- name: POD_IP # from pod metadatavalueFrom:fieldRef:fieldPath: status.podIP- name: CPU_LIMIT # from the container's resourcesvalueFrom:resourceFieldRef:containerName: webresource: limits.cpudivisor: 1m # report millicores, not whole coresresources:limits:cpu: "500m"EOF
A few things are happening here:
configMapKeyRefandsecretKeyRefpull a single named key from a ConfigMap or Secret (create those first, we do so in the next two sections).fieldRefexposes pod metadata such as the namespace, node name, or pod IP.resourceFieldRefexposes the container's own resource requests and limits. There is one sharp edge worth knowing: a CPUresourceFieldRefrounds up to the nearest whole core unless you set adivisor. Without divisor:1m, a500mlimit is reported as1. With it, you get the millicore value you expected.
Once app-config and db-creds exist, all four sources resolve inside the pod:
kubectl exec deploy/web-valuefrom -- printenv | grep -E 'LOG_LEVEL|DB_PASSWORD|POD_IP|CPU_LIMIT'CPU_LIMIT=500DB_PASSWORD=s3cr3t-p@ssLOG_LEVEL=infoPOD_IP=10.244.0.8
All four valueFrom sources resolved, including CPU_LIMIT=500 once divisor: 1m is set.
ConfigMaps (literal, from file, as env, as volume)
ConfigMaps hold non-sensitive configuration.
There are several ways to create and consume them, and the right one depends on whether your app reads config from environment variables or from files on disk.
Create a ConfigMap from literals on the command line:
kubectl create configmap app-config \--from-literal=log_level=info \--from-literal=feature_flags=beta-ui
Or from a file, where each file becomes a key:
cat > app.properties <<'EOF'log_level=infofeature_flags=beta-uiEOFkubectl create configmap app-config-file --from-file=app.properties
Inspect what you created:
kubectl get configmap app-config -o yaml
Surface every key as an environment variable with envFrom:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: web-cm-envspec:replicas: 1selector:matchLabels:app: web-cm-envtemplate:metadata:labels:app: web-cm-envspec:containers:- name: webimage: nginx:1.27envFrom:- configMapRef:name: app-configEOFkubectl exec deploy/web-cm-env -- printenv | grep -E 'log_level|feature_flags'log_level=infofeature_flags=beta-ui
Or mount the ConfigMap as files, which is the right choice for whole config files (an nginx.conf, an application.yaml) that the app reads from disk:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: web-cm-volumespec:replicas: 1selector:matchLabels:app: web-cm-volumetemplate:metadata:labels:app: web-cm-volumespec:containers:- name: webimage: nginx:1.27volumeMounts:- name: config-volumemountPath: /etc/app-configreadOnly: truevolumes:- name: config-volumeconfigMap:name: app-configEOF
Each key becomes a file under the mount path:
kubectl exec deploy/web-cm-volume -- cat /etc/app-config/log_levelinfo
One behavioural difference is worth remembering: a mounted ConfigMap updates in place when the ConfigMap changes (after a short kubelet sync), whereas env vars are fixed at pod start.
That difference often decides which method to use.
Secrets, and what base64 does not do
Secrets look a lot like ConfigMaps, with one key difference in how you should think about them. Create one imperatively:
kubectl create secret generic db-creds \--from-literal=USERNAME=admin \--from-literal=PASSWORD='s3cr3t-p@ss'
Now the most important point in this whole article. The Secret stores values base64 encoded, which is not encryption. Anyone who can read the Secret can decode it instantly:
kubectl get secret db-creds -o jsonpath='{.data.PASSWORD}' | base64 -d ; echos3cr3t-p@ss
base64 only keeps binary data safe to store as text, it provides zero confidentiality.
Treat a Secret object as plaintext to anyone with read access to it or to etcd. We harden that further down.
Decoding the stored Secret value: base64 is encoding, not encryption.
stringData vs data
When you write Secrets as YAML, prefer stringData so you do not have to base64-encode by hand. Kubernetes encodes it into data for you:
kubectl apply -f - <<'EOF'apiVersion: v1kind: Secretmetadata:name: api-credstype: OpaquestringData: # plain text in, encoded at rest by the API serverAPI_KEY: "live_abc123"data: # this field expects already-base64 valuesLEGACY_TOKEN: "bGVnYWN5LXRva2Vu"EOF
The stringData values are write-only convenience fields. After apply, both keys live under data as base64.
Secret types
Secrets are typed. The type field tells Kubernetes what shape to expect:
Opaqueis the default, arbitrary key/value pairs.kubernetes.io/dockerconfigjsonholds registry pull credentials (kubectl create secret docker-registry ...).kubernetes.io/tlsholds atls.crtandtls.keypair for Ingress and similar (kubectl create secret tls ...).kubernetes.io/basic-authholds username and password keys.
Consuming a Secret: env vars vs mounted files
Consume a Secret either as env vars or as mounted files. As env vars with envFrom:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: web-secret-envspec:replicas: 1selector:matchLabels:app: web-secret-envtemplate:metadata:labels:app: web-secret-envspec:containers:- name: webimage: nginx:1.27envFrom:- secretRef:name: db-credsEOF
Mounting as files is generally safer than env vars.
Environment variables can leak into logs, crash dumps, and child processes, whereas a mounted file can be readOnly and is easier to rotate:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: web-secret-volumespec:replicas: 1selector:matchLabels:app: web-secret-volumetemplate:metadata:labels:app: web-secret-volumespec:containers:- name: webimage: nginx:1.27volumeMounts:- name: secret-volumemountPath: /etc/secretsreadOnly: truevolumes:- name: secret-volumesecret:secretName: db-credsEOF
When you just want to confirm a Secret's shape without printing its values, describe shows the keys and byte counts only:
kubectl describe secret db-creds
Hardening native Secrets
We have established that a Secret is plaintext to anyone with read access.
Three native controls tighten that story before you reach for an external tool.
Make ConfigMaps and Secrets immutable
An immutable object cannot be updated, only deleted and recreated.
This prevents accidental changes and lets the kubelet stop watching it, which improves performance on large clusters:
kubectl apply -f - <<'EOF'apiVersion: v1kind: Secretmetadata:name: db-creds-immutabletype: Opaqueimmutable: truestringData:PASSWORD: "s3cr3t-p@ss"EOF
After this, an attempt to change the data is rejected by the API server:
kubectl patch secret db-creds-immutable -p '{"stringData":{"PASSWORD":"new"}}'The Secret "db-creds-immutable" is invalid: data: Forbidden: field is immutable when `immutable` is set
An immutable Secret rejecting a value change.
Enable encryption at rest
By default Secrets are stored in etcd as plaintext (base64).
On a self-managed control plane you harden this with an EncryptionConfiguration passed to the API server, so Secrets are encrypted before they hit disk:
apiVersion: apiserver.config.k8s.io/v1kind: EncryptionConfigurationresources:- resources:- secretsproviders:- aescbc:keys:- name: key1secret: <base64-encoded-32-byte-key>- identity: {} # allows reading existing unencrypted Secrets during rollout
For real key management, point the kms provider at a KMS plugin instead of holding the key in this file.
On managed offerings the provider handles control-plane encryption for you, so confirm what your platform already does before rolling your own.
On Civo managed Kubernetes you do not edit the API server flags directly, so treat encryption at rest as a "confirm with your platform" item rather than something you configure by hand.
Limit who can read Secrets with RBAC
Read access to Secrets is effectively read access to your credentials, so scope it tightly.
This Role grants get on a single named Secret and nothing else:
kubectl apply -f - <<'EOF'apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:namespace: defaultname: secret-readerrules:- apiGroups: [""]resources: ["secrets"]resourceNames: ["db-creds"] # this one Secret onlyverbs: ["get"]EOF
You can check what a given identity is allowed to do with kubectl auth can-i:
kubectl auth can-i get secrets --as=system:serviceaccount:default:limited-sa
A correctly scoped Role lets the service account read its one Secret while denying broad access:
can get secrets/db-creds : yescan list ALL secrets : nocan get pods : no
A Role scoped to one Secret name: get on that Secret yes, list all Secrets and get pods no.
Using external secret managers
The primitives get you a long way, but they have limits.
Native Secrets are not encrypted by default, there is no built-in rotation, and there is no single source of truth when you run many clusters or want one canonical store shared with non-Kubernetes systems.
Committing Secret YAML to git is also off the table, since the values are only encoded.
External secret managers close these gaps in different ways. There is no single winner here, so pick based on where you want secrets to live and how you deploy.
- External Secrets Operator (ESO): The most widely adopted option. It runs in the cluster, reads from an external store (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault, and more), and materialises the result as a normal Kubernetes Secret your pods consume unchanged. Pick it when your source of truth already lives in a cloud secret manager.
- Sealed Secrets: The kubeseal CLI encrypts a Secret into a SealedSecret that is safe to commit to git; only the in-cluster controller can decrypt it. Pick it when you want secrets in the same repo as your manifests, GitOps-style, with no external store to run.
- HashiCorp Vault: For dynamic secrets, leasing, and fine-grained policy. The Vault Secrets Operator syncs Vault secrets into native Kubernetes Secrets, and a CSI provider mounts them per pod instead. Pick it when you need Vault's policy engine and dynamic secrets.
- Doppler: A hosted secrets platform whose operator syncs a Doppler project into a managed Kubernetes Secret. Pick it when you want a SaaS dashboard, change history, and syncing across environments without running your own store.
Doppler is one option among these. The walkthrough below shows how to wire it up as one concrete example.
How to use Doppler to store environment variables for Kubernetes
Install the Doppler Kubernetes operator:
kubectl apply -f https://github.com/DopplerHQ/kubernetes-operator/releases/latest/download/recommended.yaml
Next you need a Doppler service token. In the Doppler dashboard, create a project if you do not have one, select the config you want to sync, then open the Access tab and click Generate to create a service token. Copy it when it is shown, you will not see it again. The service tokens docs cover scopes and expiry in more detail.
Store the token as a Secret the operator can read:
kubectl create secret generic doppler-token-secret \--namespace doppler-operator-system \--from-literal=serviceToken=<paste-service-token>
Tell the operator which Doppler config to sync and what managed Secret to produce:
kubectl apply -f - <<'EOF'apiVersion: secrets.doppler.com/v1alpha1kind: DopplerSecretmetadata:name: dopplersecret-testnamespace: doppler-operator-systemspec:tokenSecret:name: doppler-token-secretmanagedSecret:name: doppler-test-secretnamespace: defaulttype: OpaqueEOF
Consume the managed Secret like any other. The secrets.doppler.com/reload annotation rolls the Deployment when the synced values change:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: doppler-test-deployment-envfromannotations:secrets.doppler.com/reload: 'true'spec:replicas: 1selector:matchLabels:app: doppler-testtemplate:metadata:labels:app: doppler-testspec:containers:- name: doppler-testimage: alpine:3.20command: ["/bin/sh", "-c", "printenv && sleep 3600"]envFrom:- secretRef:name: doppler-test-secretEOFkubectl logs deploy/doppler-test-deployment-envfrom
The synced values appear in the container's environment, exactly as if you had created the Secret by hand.
Cleaning up
When you are done experimenting, remove the objects you created and, if you spun one up, the cluster:
kubectl delete deploy,configmap,secret,dopplersecret --all -n defaultcivo kubernetes remove env-demo
Summary
In this article, we have been able to go through how to set up and use environment variables in your Kubernetes cluster. We went from creating a cluster with Civo, demonstrated how to set environment variables from ConfigMaps, store sensitive environment variables securely, and finally showed you how to use Doppler as an example of an external tool to securely store and manage your environment variables.
Hopefully, with this information, you will be able to confidently start working with environment variables in your Kubernetes application.

Technical Writer at Civo
Jubril Oyetunji is a DevOps engineer and technical writer with a strong focus on cloud-native technologies and open-source tools. His work centers on creating practical tutorials that help developers better understand platforms such as Kubernetes, NGINX, Rust, and Go.
As a contract technical writer, Jubril authored an extensive library of technical guides covering cloud-native infrastructure and modern development workflows. Many of his tutorials achieved strong search rankings, helping developers around the world learn and adopt emerging technologies.
Share this article
Further Reading
2 February 2021
Managing Kubernetes insights and logs with Datadog
22 August 2022