1. Introduction
Managing Kubernetes manifests across multiple environments — dev, staging, production — usually means one of two things: duplicating YAML files with minor differences, or adopting a templating tool like Helm. Both approaches have real costs. Duplication creates drift. Helm adds a learning curve and chart overhead that many teams don't need for straightforward deployments.
envsubst sits in the middle. It's a small Unix utility that replaces shell variable references ($VARIABLE or ${VARIABLE}) in a file with their current values from the environment. Combined with a CI/CD pipeline that sets the right variables per environment, it lets you maintain a single set of Kubernetes manifests and inject environment-specific values at deploy time — no templating engine required.
This guide walks through setting up an envsubst-based Kubernetes deployment workflow from scratch: writing the template manifests, wiring up the pipeline, handling edge cases, and avoiding the pitfalls that catch teams out in production.
2. When and Why to Use envsubst
envsubst is a good fit when:
- You deploy the same application to multiple environments and the differences are limited to a handful of values (image tag, replica count, resource limits, ingress hostname)
- You want a simple, auditable deployment process with no extra tooling to manage or version
- Your team is comfortable with shell scripting but hasn't adopted Helm or Kustomize yet
- You're working in a CI/CD environment (GitLab CI, GitHub Actions, Jenkins) where environment variables are already first-class citizens
It's less suitable when:
- Your manifests have complex conditional logic that varies between environments
- You need reusable chart-style packaging to share across multiple teams or repositories
- You're managing many microservices with significantly different configurations — Kustomize or Helm will scale better
| Approach | Best for | Overhead |
|---|---|---|
| envsubst | Single-repo, straightforward multi-env deploys | Minimal — one binary, plain shell |
| Kustomize | Multi-env overlays with structural YAML changes | Medium — built into kubectl, YAML overlays |
| Helm | Reusable packaged charts, complex conditionals | Higher — chart structure, values files, releases |
| sed / awk | One-off variable substitution in scripts | Fragile — not purpose-built, hard to maintain |
3. Prerequisites
- envsubst installed on your CI runner (part of the gettext package)
- kubectl configured with credentials to access your target cluster (if deploying to EKS and needing AWS API access from pods, see How to Use IRSA in EKS)
- Kubernetes manifests already written for your application
- A CI/CD pipeline (GitLab CI, GitHub Actions, or Jenkins — examples provided for all three)
- Environment-specific variables available as CI/CD secrets or environment variables
# Verify envsubst is available on your runner or local machine envsubst --version
# envsubst (GNU gettext-runtime) 0.21
# Install if missing
# Ubuntu / Debian: apt-get install -y gettext-base
# Alpine Linux (common in Docker-based CI runners): apk add --no-cache gettext
# macOS: brew install gettext
4. Step-by-Step Implementation
Step 1: Write the manifest template
Replace environment-specific values in your Kubernetes manifests with shell variable placeholders. The convention is to use uppercase names with a project or environment prefix to avoid conflicts with shell built-ins.
Here is a complete Deployment template with common variable substitution points:
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment metadata: name: ${APP_NAME} namespace: ${NAMESPACE} labels: app: ${APP_NAME} version: ${IMAGE_TAG} spec: replicas: ${REPLICA_COUNT} selector: matchLabels: app: ${APP_NAME} template: metadata: labels: app: ${APP_NAME} version: ${IMAGE_TAG} spec: containers: - name: ${APP_NAME} image: ${IMAGE_REGISTRY}/${APP_NAME}:${IMAGE_TAG} ports: - containerPort: ${APP_PORT} resources: requests: cpu: ${CPU_REQUEST} memory: ${MEMORY_REQUEST} limits: cpu: ${CPU_LIMIT} memory: ${MEMORY_LIMIT} env: - name: APP_ENV value: ${APP_ENV} - name: LOG_LEVEL value: ${LOG_LEVEL}
And the matching Service and Ingress templates:
# k8s/service.yaml
apiVersion: v1
kind: Service metadata: name: ${APP_NAME} namespace: ${NAMESPACE} spec: selector: app: ${APP_NAME} ports: - port: 80 targetPort: ${APP_PORT} ---
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress metadata: name: ${APP_NAME} namespace: ${NAMESPACE} spec: rules: - host: ${INGRESS_HOST} http: paths: - path: / pathType: Prefix backend: service: name: ${APP_NAME} port: number: 80
Step 2: Create per-environment variable files
Store non-sensitive, environment-specific variable values in .env files committed to your repository. Sensitive values (passwords, tokens, keys) belong in your CI/CD platform's secret store — never in committed files.
# envs/staging.env APP_NAME=my-app NAMESPACE=staging IMAGE_REGISTRY=123456789012.dkr.ecr.us-east-1.amazonaws.com REPLICA_COUNT=1 APP_PORT=8080 APP_ENV=staging LOG_LEVEL=debug INGRESS_HOST=staging.my-app.example.com CPU_REQUEST=100m CPU_LIMIT=500m MEMORY_REQUEST=128Mi MEMORY_LIMIT=512Mi
# envs/production.env APP_NAME=my-app NAMESPACE=production IMAGE_REGISTRY=123456789012.dkr.ecr.us-east-1.amazonaws.com REPLICA_COUNT=3 APP_PORT=8080 APP_ENV=production LOG_LEVEL=warn INGRESS_HOST=my-app.example.com CPU_REQUEST=250m CPU_LIMIT=1000m MEMORY_REQUEST=256Mi MEMORY_LIMIT=1Gi
Step 3: Write the deploy script
A small deploy script loads the environment file, adds the dynamic CI/CD variables (like IMAGE_TAG), runs envsubst over the manifests, and applies them with kubectl. Keep it explicit — it should be readable by anyone on the team.
#!/bin/bash
# scripts/deploy.sh set -euo pipefail
# --- Arguments --- ENVIRONMENT=${1:-staging} ENV_FILE="envs/${ENVIRONMENT}.env" if [[ ! -f "$ENV_FILE" ]]; then echo "Error: environment file not found: $ENV_FILE" exit 1 fi
# --- Load the environment file --- set -a source "$ENV_FILE" set +a
# --- IMAGE_TAG must be set by CI (not in .env file) --- if [[ -z "${IMAGE_TAG:-}" ]]; then echo "Error: IMAGE_TAG is not set. Set it as a CI variable." exit 1 fi echo "Deploying ${APP_NAME}:${IMAGE_TAG} to ${ENVIRONMENT} (namespace: ${NAMESPACE})"
# --- Substitute variables and apply manifests ---
# List only the variables you want replaced to avoid substituting
# unintended shell variables in YAML values VARS='${APP_NAME}:${NAMESPACE}:${IMAGE_REGISTRY}:${IMAGE_TAG}' VARS+=':${REPLICA_COUNT}:${APP_PORT}:${APP_ENV}:${LOG_LEVEL}' VARS+=':${INGRESS_HOST}:${CPU_REQUEST}:${CPU_LIMIT}' VARS+=':${MEMORY_REQUEST}:${MEMORY_LIMIT}' for manifest in k8s/*.yaml; do envsubst "$VARS" < "$manifest" | kubectl apply -f - done
# --- Wait for rollout --- kubectl rollout status deployment/${APP_NAME} -n ${NAMESPACE} --timeout=120s echo "Deployment complete."
Step 4: Wire up the CI/CD pipeline
Below are complete pipeline configurations for the three most common CI/CD platforms.
GitLab CI
# .gitlab-ci.yml stages: - build - deploy variables: IMAGE_REGISTRY: "123456789012.dkr.ecr.us-east-1.amazonaws.com" build: stage: build image: docker:24 services: - docker:24-dind script: - export IMAGE_TAG=${CI_COMMIT_SHORT_SHA} - aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $IMAGE_REGISTRY - docker build -t ${IMAGE_REGISTRY}/${CI_PROJECT_NAME}:${IMAGE_TAG} . - docker push ${IMAGE_REGISTRY}/${CI_PROJECT_NAME}:${IMAGE_TAG} deploy_staging: stage: deploy image: bitnami/kubectl:latest environment: staging script: - apt-get install -y gettext-base - export IMAGE_TAG=${CI_COMMIT_SHORT_SHA} - bash scripts/deploy.sh staging only: - main deploy_production: stage: deploy image: bitnami/kubectl:latest environment: production script: - apt-get install -y gettext-base - export IMAGE_TAG=${CI_COMMIT_SHORT_SHA} - bash scripts/deploy.sh production only: - tags when: manual
GitHub Actions
# .github/workflows/deploy.yml name: Deploy on: push: branches: [main] release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} aws-region: us-east-1 - name: Set image tag run: echo "IMAGE_TAG=${GITHUB_SHA::8}" >> $GITHUB_ENV - name: Build and push to ECR run: | aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin \ ${{ secrets.IMAGE_REGISTRY }} docker build -t ${{ secrets.IMAGE_REGISTRY }}/my-app:$IMAGE_TAG . docker push ${{ secrets.IMAGE_REGISTRY }}/my-app:$IMAGE_TAG - name: Deploy to staging if: github.ref == 'refs/heads/main' env: IMAGE_REGISTRY: ${{ secrets.IMAGE_REGISTRY }} KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_STAGING }} run: | echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig export KUBECONFIG=/tmp/kubeconfig bash scripts/deploy.sh staging - name: Deploy to production if: github.event_name == 'release' env: IMAGE_REGISTRY: ${{ secrets.IMAGE_REGISTRY }} KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_PRODUCTION }} run: | echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig export KUBECONFIG=/tmp/kubeconfig bash scripts/deploy.sh production
Jenkins
// Jenkinsfile pipeline { agent { label 'docker' } environment { IMAGE_TAG = "${env.GIT_COMMIT[0..7]}" IMAGE_REGISTRY = credentials('ecr-registry-url') } stages { stage('Build & Push') { steps { sh '''
aws ecr get-login-password --region us-east-1 \ |
docker login --username AWS --password-stdin $IMAGE_REGISTRY
docker build -t ${IMAGE_REGISTRY}/my-app:${IMAGE_TAG} .
docker push ${IMAGE_REGISTRY}/my-app:${IMAGE_TAG} ''' } } stage('Deploy Staging') { when { branch 'main' } steps { withKubeConfig([credentialsId: 'kubeconfig-staging']) { sh 'bash scripts/deploy.sh staging' } } } stage('Deploy Production') { when { tag '*' } input { message 'Deploy to production?' } steps { withKubeConfig([credentialsId: 'kubeconfig-production']) { sh 'bash scripts/deploy.sh production' } } } } }
Step 5: Preview substituted output before applying
Before trusting the pipeline in production, add a dry-run step that prints the substituted YAML without applying it. This makes it easy to catch substitution errors during development and in pull request pipelines:
# Add a preview target to your deploy script
# Usage: bash scripts/deploy.sh staging --preview PREVIEW=${2:-} for manifest in k8s/*.yaml; do substituted=$(envsubst "$VARS" < "$manifest") if [[ "$PREVIEW" == "--preview" ]]; then echo "=== ${manifest} ===" echo "$substituted" echo "" else echo "$substituted" | kubectl apply -f - fi done
# In CI — add a lint/preview job on pull requests:
# bash scripts/deploy.sh staging --preview
# Optionally pipe through kubeval or kubeconform for schema validation:
# envsubst "$VARS" < k8s/deployment.yaml | kubeconform -
5. Testing and Validation
After running a deployment, verify the rollout succeeded and the correct image is running. If the pod gets stuck after deploy, check for ImagePullBackOff first — it's the most common post-deploy failure when image tags change.
# Check the rollout completed successfully kubectl rollout status deployment/${APP_NAME} -n ${NAMESPACE}
# Confirm the running image tag matches what was deployed kubectl get deployment ${APP_NAME} -n ${NAMESPACE} \ -o jsonpath='{.spec.template.spec.containers[0].image}'
# Expected: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:a1b2c3d4
# Verify replica count matches the environment file kubectl get deployment ${APP_NAME} -n ${NAMESPACE}
# Check environment variables were injected correctly kubectl exec deployment/${APP_NAME} -n ${NAMESPACE} -- env | grep APP_ENV
# Confirm resource requests and limits kubectl get pod -n ${NAMESPACE} -l app=${APP_NAME} -o jsonpath=\ '{.items[0].spec.containers[0].resources}'
Add these checks to the end of your deploy script so the pipeline fails visibly if any verification step fails — rather than reporting success and leaving an inconsistent deployment in place.
6. Common Issues
Variables not substituted — dollar signs appear literally in the output
This almost always means the variable was not exported into the environment before envsubst ran, or the variable name in the template doesn't match the name in the .env file exactly (envsubst is case-sensitive):
# Debug: print all variables envsubst can see env | grep -E '^(APP | IMAGE | NAMESPACE | REPLICA | INGRESS)'
# Ensure variables are exported (set -a exports all sourced variables) set -a && source envs/staging.env && set +a
# Confirm the variable name matches exactly — case matters
# Template uses ${Image_Tag} but variable is IMAGE_TAG — will not substitute
envsubst replaces variables it shouldn't
If you forget to pass the explicit variable list, envsubst will substitute every shell variable in scope. Kubernetes YAML with dollar signs in annotation values, shell scripts in ConfigMaps, or Prometheus query strings are all vulnerable:
# Wrong — substitutes ALL shell variables: envsubst < k8s/deployment.yaml | kubectl apply -f -
# Correct — substitutes only the listed variables: envsubst '${APP_NAME}:${IMAGE_TAG}:${NAMESPACE}' < k8s/deployment.yaml \ | kubectl apply -f -
kubectl apply fails after substitution
If kubectl apply errors immediately after envsubst, the most likely cause is invalid YAML from a failed substitution. If the deployment applies but the pod keeps crashing, see Fix Kubernetes CrashLoopBackOff for a systematic diagnosis. Print the output to inspect it before applying:
# Print the substituted output and validate it envsubst '${APP_NAME}:${IMAGE_TAG}' < k8s/deployment.yaml
# Validate the YAML structure envsubst '${APP_NAME}:${IMAGE_TAG}' < k8s/deployment.yaml \ | kubectl apply --dry-run=client -f -
# Or use kubeconform for schema validation envsubst '${APP_NAME}:${IMAGE_TAG}' < k8s/deployment.yaml \ | kubeconform -kubernetes-version 1.29.0 -
IMAGE_TAG is empty in the pipeline
A blank IMAGE_TAG results in an image reference like myapp: with no tag, which Kubernetes treats as :latest — a silent and dangerous misconfiguration. The deploy script already guards against this with an explicit check, but confirm your CI/CD platform is setting IMAGE_TAG before the deploy step runs and that the variable is correctly exported between pipeline stages.
7. Summary
envsubst is a lightweight, portable way to parameterise Kubernetes manifests in CI/CD pipelines without adopting a full templating system. The pattern is straightforward:
- Write manifests with ${VARIABLE} placeholders for environment-specific values
- Store non-sensitive env values in committed .env files per environment
- Keep dynamic and sensitive values (IMAGE_TAG, credentials) in CI/CD secrets and pipeline variables
- Run envsubst with an explicit variable list before kubectl apply
- Validate the substituted output with kubectl apply --dry-run or kubeconform in PR pipelines
| Component | Purpose | Key rule |
|---|---|---|
| k8s/*.yaml templates | Parameterised manifests | Use ${VAR} syntax, not $VAR — more explicit |
| envs/<env>.env files | Non-sensitive per-env values | Commit these — no secrets, no credentials |
| CI/CD secrets | Sensitive and dynamic values | IMAGE_TAG, kubeconfig, passwords go here |
| scripts/deploy.sh | Load env, run envsubst, apply | Guard against empty IMAGE_TAG explicitly |
| Explicit variable list | Prevents unintended substitution | Always pass vars as envsubst first argument |
| kubectl rollout status | Confirm deploy succeeded | Add --timeout and fail the pipeline on error |