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:

It's less suitable when:

ApproachBest forOverhead
envsubstSingle-repo, straightforward multi-env deploysMinimal — one binary, plain shell
KustomizeMulti-env overlays with structural YAML changesMedium — built into kubectl, YAML overlays
HelmReusable packaged charts, complex conditionalsHigher — chart structure, values files, releases
sed / awkOne-off variable substitution in scriptsFragile — not purpose-built, hard to maintain

3. Prerequisites

# 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:

ComponentPurposeKey rule
k8s/*.yaml templatesParameterised manifestsUse ${VAR} syntax, not $VAR — more explicit
envs/<env>.env filesNon-sensitive per-env valuesCommit these — no secrets, no credentials
CI/CD secretsSensitive and dynamic valuesIMAGE_TAG, kubeconfig, passwords go here
scripts/deploy.shLoad env, run envsubst, applyGuard against empty IMAGE_TAG explicitly
Explicit variable listPrevents unintended substitutionAlways pass vars as envsubst first argument
kubectl rollout statusConfirm deploy succeededAdd --timeout and fail the pipeline on error