Skip to main content
DevOps from zero · Lesson 10 of 15 · 13 min

Docker Env Vars and Secrets: Build Time vs Runtime

Learn how env vars and secrets work in a Docker deploy: build time vs runtime, GitHub secrets, env_file, and why ENV and ARG must never hold a secret.

By Matthews WongReviewed on

Key takeaway

In a Docker deploy, build-time values are baked into the image while runtime values are given to the container when it starts. Secrets belong at runtime only: kept in GitHub, written to an owner-only env file on the server, and read by the container. ENV and ARG must never hold a secret, because they end up in the image.

You will be able to

  • Sort any setting into build time or runtime, and say where a secret may and may not live.
  • Trace a GitHub secret from the repository to process.env inside the container.
  • Predict which Compose source wins when the same variable is set twice.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

Where a secret lives at every stage of a deploy

The image is built with no secrets. They stay in GitHub until the deploy job writes them to an env file on the server, and Compose hands that file to the container when it starts.

Step 1 of 6. On the left, GitHub builds an image and holds the secrets. On the right, the server holds a file and runs the container. Follow where a secret goes.

Keys: ← → step · Home / End · K play or pause

  1. On the left, GitHub builds an image and holds the secrets. On the right, the server holds a file and runs the container. Follow where a secret goes.

  2. The build only sees the repository's files. .dockerignore keeps .env files out of the build context, and the Dockerfile declares no ARG, so the image carries code and harmless ENV only.

    .env .env.*Predict the next step The image holds no secrets. Where do the app's secrets wait until the server needs them? (In the Dockerfile as ENV · In GitHub's encrypted secrets · In the repository's .env file)
  3. GitHub stores secrets encrypted, and a workflow can only read one it names explicitly. Here only the deploy job names the app's secrets.

    secrets context vars context
  4. Over SSH, the deploy job writes the values into .env with a quoted heredoc, then makes the file owner-only. The values never touched the image.

    write .env chmod 600 .envPredict the next step How does the running container get those values? (Compose passes the file with env_file · The app downloads them from GitHub · They were baked into the image)
  5. Compose pulls the SHA-tagged image and passes .env to the container through env_file. The same file also fills the IMAGE and APP_PORT substitutions in the Compose file.

    env_file: .env
  6. The server code reads process.env while it runs, so one image works with any .env. The exception is NEXT_PUBLIC values, which Next.js freezes into the JavaScript at build time.

What is the difference between build-time and runtime configuration?

Build time is when docker build runs: every instruction leaves a layer, and anything set there can end up inside the image. Runtime is when a container starts from that image and is handed its environment. The image is shared, copied to a registry and pulled by servers, so whatever is baked in travels with it. The environment is given to one container on one machine.

Read more (2)

Docker's Dockerfile reference is blunt about the two instructions that look like configuration. ENV values are always persisted in the built image and carry into every container run from it. ARG values are not embedded in the image or available in the final container, but build arguments can still be seen with docker history, and Docker says plainly that it is not recommended to pass secrets through them.

When a build truly needs a credential, for example to fetch a private package, Docker's answer is a secret mount: RUN --mount=type=secret makes the value available to that one instruction, by default as a file under /run/secrets, and it is supplied on the command line with --secret. This site never needs it. Its Dockerfile declares no ARG, and the only ENV lines set harmless values such as the port.

Why is a NEXT_PUBLIC variable different from a server variable?

Next.js splits variables by where they may be read. Without a prefix, a variable exists only in the Node.js environment. With the NEXT_PUBLIC_ prefix, Next.js inlines the value into the JavaScript bundle at next build, replacing each reference with a hard-coded value that anyone can read in the browser. So a NEXT_PUBLIC value can never be a secret.

Read more (2)

The same inlining makes it build-time configuration. Next.js's docs note that after the build the app no longer responds to changes in those variables, and that a single Docker image promoted through several environments freezes them at the values from the build. Dynamic lookups such as process.env[name] are not inlined at all. Server variables are the opposite: they can be read at request time during dynamic rendering, which is what lets one image run with different values.

This site's two secrets, an AI provider key and an email provider key, are server-side only. They are read from process.env inside API route handlers on the server, never in a component that ships to the browser. That is the shape to copy: public identifiers at build time, secrets at runtime on the server.

How do GitHub secrets and variables reach the server?

GitHub secrets are encrypted before they reach GitHub, with libsodium sealed boxes, and can live at the organization, repository or environment level. A workflow can only read a secret it explicitly names through the secrets context. Secrets are not passed to the runner when a workflow is triggered from a fork, not passed automatically to reusable workflows, and not available to Dependabot-triggered workflows, so a step that expects one will receive nothing.

Read more (2)

Plain configuration goes in variables, read through the vars context, and an unset variable returns an empty string. That is why the workflow writes vars.APP_PORT || 3002: an empty value falls back to a default. The rule of thumb on this site: anything that would be harmful if posted publicly is a secret, everything else is a variable.

GitHub evaluates every dollar-brace expression and substitutes the result before the shell script runs, so by the time the SSH step executes, the heredoc already contains the real values. Bash says that if any part of the heredoc delimiter is quoted, the lines are not expanded, which is why the delimiter is written 'ENVEOF' with quotes: a value containing a dollar sign stays literal instead of being treated as a variable.

How does Docker Compose turn .env into the container's environment?

A .env file does two jobs here. Placed next to the Compose file, it feeds interpolation: ${IMAGE} and ${APP_PORT:-3002} in the YAML are replaced from it, where the :- form uses the default when the variable is unset or empty and ${VAR:?error} stops with an error instead. And env_file: .env loads the same file into the container's environment. Interpolation is a Compose feature, which docker run --env-file does not support.

Read more (2)

When one variable is set in several places, Compose resolves it in a fixed order, highest first: docker compose run -e, then environment or env_file entries with an interpolated value, then environment alone, then env_file alone, and last an ENV baked into the image. A Dockerfile ENV therefore only applies when Compose sets nothing for that name. With several env_file entries, later files override earlier ones.

There is an honest trade-off here. Compose's own documentation advises against passing passwords and API keys as environment variables, because they are often available to all processes in the container and hard to track, and offers Compose secrets, mounted as files under /run/secrets. This site keeps env_file: a single container on a single machine, an owner-only file, and secrets that exist only on the server. Know that the stronger option exists before choosing the simpler one.

How do you keep a secret out of the image, the repository and the logs?

Out of the repository: .gitignore lists .env files, and Next.js's own docs say you almost never want to commit them. Out of the image: .dockerignore excludes .env and .env.* from the build context, and Docker's rule is that anything excluded cannot be copied by a COPY instruction, so a careless COPY . . cannot sweep them in.

Read more (2)

Out of the logs: GitHub redacts secrets printed to the log, but its docs say this redaction is not guaranteed, because a secret can be transformed in many ways, and the runner can only redact secrets used in the current job. So never print one. If a secret produces another sensitive value, register that value for masking too, and avoid wrapping several secrets in one JSON or YAML blob; create one secret per value.

And plan for the day one leaks. GitHub's hardening guide recommends granting the minimum permissions, auditing how secrets are used, and rotating them periodically to shorten the window in which a compromised one is valid. This workflow applies the first rule to its own token: each job lists only the permissions it needs.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.github/workflows/deploy.yml:224-234
            # Write runtime env from GitHub Secrets/Variables. Quoted heredoc so
            # shell does not expand anything in the (already-rendered) values.
            cat > .env <<'ENVEOF'
            GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
            RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
            CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
            CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
            IMAGE=${{ needs.build-and-push.outputs.image }}
            APP_PORT=${{ vars.APP_PORT || 3002 }}
            ENVEOF
            chmod 600 .env

The only place the app's secrets and variables meet. The first two lines are secrets, the next two are variables, then IMAGE and APP_PORT, which Compose uses for substitution. Quotes around ENVEOF stop the shell from expanding a dollar sign inside a value, and chmod 600 leaves the file readable by its owner only.

docker-compose.yml:1-11
# Runtime compose used on the VPS. The image is built + pushed to GHCR by
# .github/workflows/deploy.yml; this file is copied to the server, and CI
# writes a sibling .env (from GitHub Secrets/Variables) that supplies both the
# ${IMAGE}/${APP_PORT} substitutions below and the container's runtime env.
services:
  web:
    image: ${IMAGE:-ghcr.io/matthews-wong/matthews-porto-nextjs:latest}
    # Matches the existing container so deploys UPDATE it in place (no duplicate).
    container_name: matthewswong-portfolio
    restart: unless-stopped
    env_file: .env

The header comment states the contract: CI writes a sibling .env that supplies both the substitutions and the container's runtime env. IMAGE has a default after the :- so the file still parses without CI. env_file: .env is the line that hands every value to the container.

.dockerignore:4-7
node_modules
.git
.env
.env.*

The two .env lines are the guard for the image. .env and .env.* never enter the build context, so no COPY in the Dockerfile can place a local secrets file into a layer, even on a laptop that has one.

Dockerfile:38-41
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

Every ENV in the runner stage is harmless: a mode flag, a telemetry switch, a port and a bind address. These are fine to bake in because they are identical everywhere and anyone may read them. Nothing here, or anywhere in the file, is a credential.

.github/workflows/deploy.yml:113-118
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

Not every credential is a stored secret. The registry login uses the job's GITHUB_TOKEN, which GitHub creates per job, so there is no long-lived registry password to store or leak. The secrets context reads it, but it is not one you ever create.

What breaks

Mistakes that cost real time

  • A secret passed as ARG or set with ENV

    ENV is always persisted in the image, and a build argument can be read with docker history, so anyone who can pull the image can read the key. Treat it as leaked: rotate it, then switch to RUN --mount=type=secret for the build or runtime delivery for the app.

  • Changing a NEXT_PUBLIC value on the server

    You edit .env and restart the container, but the browser still shows the old value. Next.js inlined it into the JavaScript at next build, so the running app no longer responds to it. Rebuild the image with the new value, or serve the value from an API instead.

  • A secret that prints in the log anyway

    GitHub's redaction is not guaranteed: a secret that was encoded or otherwise transformed no longer matches the stored value, and the runner can only redact secrets used in the current job. Never echo a secret, and register any derived sensitive value with a mask.

  • An unquoted heredoc that mangles a value

    With a plain ENVEOF delimiter, bash expands the lines, so a password containing a dollar sign or backticks is silently changed before it lands in .env, and the app then fails to authenticate. Quote the delimiter and the value is written exactly as GitHub rendered it.

  • A secret that arrives empty

    A workflow triggered from a fork or by Dependabot does not receive the repository's secrets, and an unset variable is an empty string, so the step runs with blank values. Use ${VAR:?error} in Compose for values that must exist, so a blank one fails loudly at startup.

Where a value can live, and what each place is fit for
When it existsENV: build and container. ARG: build onlyOne build instructionIn the running containerIn the running container
Ends up in the imageENV always. ARG shows in docker historyNoNoNo
Form insideEnvironment variableA file under /run/secretsEnvironment variableA file under /run/secrets
Fit for secretsNoYes, for a build-time credentialDocs advise against for passwordsYes, the documented option
Try it yourself

Do it on your own machine

You need Docker only. Use a made-up value, never a real key. You will see an ARG leak into docker history, keep the same value out with a secret mount, then watch Compose resolve a variable set in two places.

  1. Write a Dockerfile that takes the value as a build argument and uses it once. Build it and search the history for the value.

    FROM alpine:3
    ARG DEMO_TOKEN
    RUN test -n "$DEMO_TOKEN"
    
    # then, in a terminal
    docker build --build-arg DEMO_TOKEN=not-a-real-secret -t env-lab:arg .
    docker history --no-trunc env-lab:arg
  2. Now deliver the same value as a build secret. The Dockerfile reads it from a file for one instruction, and the history should no longer show it.

    FROM alpine:3
    RUN --mount=type=secret,id=demo test -s /run/secrets/demo
    
    # then, in a terminal
    printf "not-a-real-secret" > demo.txt
    docker build --secret id=demo,src=demo.txt -t env-lab:secret .
    docker history --no-trunc env-lab:secret
  3. Create a Compose project: a compose.yaml that loads .env, and a .env that sets GREETING. Run a throwaway container and print the variable.

    # compose.yaml
    services:
      web:
        image: alpine:3
        env_file: .env
    
    # .env
    GREETING=from-env-file
    
    # then, in a terminal
    docker compose run --rm web printenv GREETING
  4. Add an environment entry that sets GREETING again, run the same command, and see which value wins. Predict the answer first.

    services:
      web:
        image: alpine:3
        env_file: .env
        environment:
          GREETING: from-compose-file
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A Dockerfile has ARG API_KEY, and a RUN step uses it to call a private service. The image is pushed. Where can the key still be found?
  2. 2.You change NEXT_PUBLIC_ANALYTICS_ID in the server's .env and restart the container. The browser still uses the old ID. Why?
  3. 3.GREETING is set in a file loaded by env_file and again under environment in the same Compose service. Which value does the container see?
Think it through

Answer in your head, then check

  1. 1.Why must a database password never be an ENV in the Dockerfile?Show the answer

    Answer:ENV values are always persisted in the built image and carry into every container, so anyone who can pull the image can read it. Deliver it at runtime instead.

  2. 2.Why is the heredoc delimiter in the deploy step written 'ENVEOF' with quotes?Show the answer

    Answer:Bash does not expand the lines of a heredoc when any part of the delimiter is quoted. GitHub has already substituted the values, so quoting keeps a dollar sign inside a value literal.

  3. 3.What does ${APP_PORT:-3002} do in the Compose file?Show the answer

    Answer:It uses the value of APP_PORT when that variable is set and not empty, and the default after :- otherwise. The workflow writes a matching fallback, vars.APP_PORT || 3002, into .env.

Go deeper

Related deep dives on this site