← All posts

How to Inject Environment Variables into the Build Process

Published

"Inject environment variables to the build process" is the name of a checkbox in Jenkins, which is why so many people search that exact phrase. But the underlying question is universal: how do you get configuration and secrets into a build without hard coding them, and without leaking them into the artifact you ship.

This covers Jenkins first because that is where the phrase comes from, then GitHub Actions, Docker, and frontend bundlers, where the same problem shows up with different tooling.

Build time versus runtime

Before the how, the distinction that determines everything else.

Runtime injection means the variable exists when the application starts. A Node server reads process.env.DATABASE_URL on boot. Change the variable, restart, new value. The artifact does not contain the value.

Build time injection means the variable is substituted into the code during compilation. Frontend bundlers do this: import.meta.env.VITE_API_URL becomes the literal string in the shipped JavaScript. The artifact contains the value forever.

The rule that follows: never inject a secret at build time into anything that ships to a browser. It ends up in the bundle, readable by anyone who opens devtools. Build time is for public configuration like API base URLs. Secrets belong at runtime, on a server, or in CI steps that never reach the artifact.

Jenkins

The EnvInject checkbox

In a freestyle job: Configure, scroll to Build Environment, tick Inject environment variables to the build process. It requires the EnvInject plugin. You then get a text box for KEY=value lines, or a path to a properties file.

API_BASE_URL=https://api.example.com
NODE_ENV=production

Those variables are available to every build step that follows. This is fine for non sensitive configuration. It is the wrong place for secrets, because the values sit in the job config in plaintext and appear in the build's environment dump.

Declarative pipeline

In a Jenkinsfile, the environment block does the same job and is version controlled:

pipeline {
  agent any
  environment {
    API_BASE_URL = 'https://api.example.com'
    NODE_ENV = 'production'
  }
  stages {
    stage('Build') {
      steps {
        sh 'npm run build'
      }
    }
  }
}

Scope it to a single stage by putting environment inside that stage instead.

Secrets in Jenkins

Use the Credentials store, not EnvInject. Add the secret under Manage Jenkins, Credentials, as a Secret text. Then bind it:

environment {
  STRIPE_SECRET_KEY = credentials('stripe-secret-key')
}

Or scoped to a step:

withCredentials([string(credentialsId: 'stripe-secret-key', variable: 'STRIPE_SECRET_KEY')]) {
  sh 'npm run deploy'
}

Jenkins masks bound credentials in console output. EnvInject does not.

GitHub Actions

Non secret configuration goes in env, at workflow, job, or step level:

env:
  NODE_ENV: production

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      API_BASE_URL: https://api.example.com
    steps:
      - run: npm run build

Secrets go in repository or environment secrets (Settings, Secrets and variables, Actions) and are referenced with the secrets context:

steps:
  - run: npm run deploy
    env:
      STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

GitHub masks secret values in logs. Two things it does not protect against: echoing a secret after transforming it (base64, for instance) and secrets being consumed by a build step that bakes them into an artifact. Both are on you.

For non sensitive values you want to manage outside the workflow file, use the vars context, which reads from Repository variables.

Docker

Build arguments

ARG makes a value available during the image build only:

ARG API_BASE_URL
RUN echo "VITE_API_URL=${API_BASE_URL}" > .env
RUN npm run build
docker build --build-arg API_BASE_URL=https://api.example.com .

Build args are not present in the final container's environment, but they are visible in the image history via docker history. Do not pass secrets this way.

Runtime environment

ENV in the Dockerfile or -e at run time sets variables in the running container:

docker run -e DATABASE_URL=postgres://... myapp

Or from a file:

docker run --env-file .env myapp

This is the correct place for secrets a backend reads at startup. The value is not in the image.

Build secrets

Docker BuildKit has a dedicated mechanism for secrets needed during the build, such as a private npm token, that must not end up in any layer:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install
docker build --secret id=npmrc,src=$HOME/.npmrc .

The file is mounted for that step only and never written to a layer.

Frontend bundlers

This is where build time injection is the only option, because the browser has no process.env.

Vite exposes any variable prefixed VITE_ from .env files as import.meta.env.VITE_*. The prefix is deliberate: it stops you accidentally shipping DATABASE_URL to the browser.

Webpack uses DefinePlugin or dotenv-webpack to replace process.env.X with literal strings at build time.

Next.js exposes NEXT_PUBLIC_* to the browser and keeps everything else server side.

In every case the value is compiled into the JavaScript. Only put things there that you would be happy to see in a public GitHub repo.

Where the values should come from

Every system above has a place to put a value. The problem is that a real project has a handful of variables across four or five of these systems, and they drift. The Jenkins job has one database URL, the GitHub workflow has another, someone's Docker Compose file has a third.

Two ways to handle that. The first is a single .env.example in the repo listing every variable, so at least everyone knows what should exist. If you are not familiar with the convention, what is a .env file covers it.

The second is a secrets manager as the single source, with each pipeline pulling from it at build or deploy time rather than holding its own copy. With Krypt, which we build, that looks like:

- run: npm install -g @kryptorg/cli
- run: krypt run --env production -- npm run deploy
  env:
    KRYPT_API_KEY: ${{ secrets.KRYPT_API_KEY }}

One secret in CI (the Krypt key), and every other variable comes from the shared store, so rotating a credential updates every pipeline on the next run. Doppler and Infisical work the same way with their own CLIs, and this comparison covers the trade offs. For a solo project with one pipeline, this is overkill and the native secrets store of whatever CI you use is fine.

Common mistakes

Secrets in build args. They show in docker history. Use BuildKit secrets or runtime env.

Secrets in Vite or Next public variables. They ship to the browser. Only public config goes through the VITE_ or NEXT_PUBLIC_ prefix.

EnvInject for credentials. Plaintext in job config, unmasked in logs. Use the Credentials store.

Echoing secrets in CI logs. echo $SECRET is masked; echo $SECRET | base64 is not.

Committing the .env file. Add it to .gitignore before the first commit, not after.

FAQ

What does "inject environment variables to the build process" mean in Jenkins? It is the EnvInject plugin's option to set KEY=value pairs that are available to every build step in a freestyle job. In a Jenkinsfile the equivalent is the environment block.

Should I inject secrets at build time? Only if the build step needs them and the artifact does not keep them, as with BuildKit --mount=type=secret. Never into a frontend bundle.

How do I pass environment variables to a Docker build? --build-arg for values the build needs, -e or --env-file at run time for values the app needs, --secret for credentials the build needs but must not persist.

Why can't my React app read process.env at runtime? Browsers have no process environment. Bundlers replace process.env.X with literal strings at build time. Values must be known when you build, or fetched from an endpoint at runtime.

How do I keep environment variables in sync across Jenkins, GitHub Actions and Docker? A .env.example in the repo for the list, and either careful manual updates or a secrets manager as the single source each pipeline pulls from.