Managing environment variables on Kubernetes

Learn how to manage your environment variables when working with Kubernetes from configMaps to Secrets and external tools.

8 minutes reading time

Written by

Jubril Oyetunji
Jubril Oyetunji

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

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/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27
env:
- name: HOST_NAME
value: "server1.example.com"
- name: PORT_NUMBER
value: "9001"
EOF

Confirm the values landed inside the container:

kubectl exec deploy/web -- printenv | grep -E 'HOST_NAME|PORT_NUMBER'
HOST_NAME=server1.example.com
PORT_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/v1
kind: Deployment
metadata:
name: web-valuefrom
spec:
replicas: 1
selector:
matchLabels:
app: web-valuefrom
template:
metadata:
labels:
app: web-valuefrom
spec:
containers:
- name: web
image: nginx:1.27
env:
- name: LOG_LEVEL # from a ConfigMap key
valueFrom:
configMapKeyRef:
name: app-config
key: log_level
- name: DB_PASSWORD # from a Secret key
valueFrom:
secretKeyRef:
name: db-creds
key: PASSWORD
- name: POD_IP # from pod metadata
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: CPU_LIMIT # from the container's resources
valueFrom:
resourceFieldRef:
containerName: web
resource: limits.cpu
divisor: 1m # report millicores, not whole cores
resources:
limits:
cpu: "500m"
EOF

A few things are happening here:

  • configMapKeyRef and secretKeyRef pull a single named key from a ConfigMap or Secret (create those first, we do so in the next two sections).
  • fieldRef exposes pod metadata such as the namespace, node name, or pod IP.
  • resourceFieldRef exposes the container's own resource requests and limits. There is one sharp edge worth knowing: a CPU resourceFieldRef rounds up to the nearest whole core unless you set a divisor. Without divisor: 1m, a 500m limit is reported as 1. 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=500
DB_PASSWORD=s3cr3t-p@ss
LOG_LEVEL=info
POD_IP=10.244.0.8
Inline env and valueFrom

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=info
feature_flags=beta-ui
EOF
kubectl 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/v1
kind: Deployment
metadata:
name: web-cm-env
spec:
replicas: 1
selector:
matchLabels:
app: web-cm-env
template:
metadata:
labels:
app: web-cm-env
spec:
containers:
- name: web
image: nginx:1.27
envFrom:
- configMapRef:
name: app-config
EOF
kubectl exec deploy/web-cm-env -- printenv | grep -E 'log_level|feature_flags'
log_level=info
feature_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/v1
kind: Deployment
metadata:
name: web-cm-volume
spec:
replicas: 1
selector:
matchLabels:
app: web-cm-volume
template:
metadata:
labels:
app: web-cm-volume
spec:
containers:
- name: web
image: nginx:1.27
volumeMounts:
- name: config-volume
mountPath: /etc/app-config
readOnly: true
volumes:
- name: config-volume
configMap:
name: app-config
EOF

Each key becomes a file under the mount path:

kubectl exec deploy/web-cm-volume -- cat /etc/app-config/log_level
info

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 ; echo
s3cr3t-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.

Secrets, and what base64 does not do

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: v1
kind: Secret
metadata:
name: api-creds
type: Opaque
stringData: # plain text in, encoded at rest by the API server
API_KEY: "live_abc123"
data: # this field expects already-base64 values
LEGACY_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:

  • Opaque is the default, arbitrary key/value pairs.
  • kubernetes.io/dockerconfigjson holds registry pull credentials (kubectl create secret docker-registry ...).
  • kubernetes.io/tls holds a tls.crt and tls.key pair for Ingress and similar (kubectl create secret tls ...).
  • kubernetes.io/basic-auth holds 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/v1
kind: Deployment
metadata:
name: web-secret-env
spec:
replicas: 1
selector:
matchLabels:
app: web-secret-env
template:
metadata:
labels:
app: web-secret-env
spec:
containers:
- name: web
image: nginx:1.27
envFrom:
- secretRef:
name: db-creds
EOF

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/v1
kind: Deployment
metadata:
name: web-secret-volume
spec:
replicas: 1
selector:
matchLabels:
app: web-secret-volume
template:
metadata:
labels:
app: web-secret-volume
spec:
containers:
- name: web
image: nginx:1.27
volumeMounts:
- name: secret-volume
mountPath: /etc/secrets
readOnly: true
volumes:
- name: secret-volume
secret:
secretName: db-creds
EOF

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: v1
kind: Secret
metadata:
name: db-creds-immutable
type: Opaque
immutable: true
stringData:
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
Hardening native Secrets

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/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <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/v1
kind: Role
metadata:
namespace: default
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["db-creds"] # this one Secret only
verbs: ["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 : yes
can list ALL secrets : no
can get pods : no
Limit who can read Secrets with RBAC

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/v1alpha1
kind: DopplerSecret
metadata:
name: dopplersecret-test
namespace: doppler-operator-system
spec:
tokenSecret:
name: doppler-token-secret
managedSecret:
name: doppler-test-secret
namespace: default
type: Opaque
EOF

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/v1
kind: Deployment
metadata:
name: doppler-test-deployment-envfrom
annotations:
secrets.doppler.com/reload: 'true'
spec:
replicas: 1
selector:
matchLabels:
app: doppler-test
template:
metadata:
labels:
app: doppler-test
spec:
containers:
- name: doppler-test
image: alpine:3.20
command: ["/bin/sh", "-c", "printenv && sleep 3600"]
envFrom:
- secretRef:
name: doppler-test-secret
EOF
kubectl 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 default
civo 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.

Jubril Oyetunji
Jubril Oyetunji

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.

View author profile