|Docs

Handle Build-Time vs Runtime Secrets in Docker Builds

dockersecretsbuildsenvironment-variables

A build-time secret is a value your Dockerfile needs while the image is being built, such as a private registry token or an npm auth token. A runtime secret is a value your application reads after the container starts, such as a database URL or a Stripe key. Mixing the two up causes one of two failure modes: a build that fails because a variable is undefined, or a secret permanently baked into image layers where anyone with the image can read it.

Avoiding both starts with knowing how Railway exposes variables to Docker builds, how to consume them at each phase, and how to keep runtime secrets out of the final image.

How Railway injects variables

Railway makes your service variables available in two places:

  • The build process. Every variable you define, plus Railway-provided variables like RAILWAY_ENVIRONMENT, is available while your service builds.
  • The running deployment. The same variables are injected as plain environment variables into the container at start.

There is one difference between build systems:

  • Railpack builds (Railway's default builder) expose variables to the build automatically.
  • Dockerfile builds do not. Docker isolates the build from the host environment by design. To use a Railway variable during a Dockerfile build, you must opt in with an ARG instruction.

At runtime there is no difference. Your process reads process.env.DATABASE_URL (or the equivalent in your language) regardless of how the image was built.

Use a variable at build time with ARG

Declare the variable with ARG in your Dockerfile. Railway matches the ARG name against your service variables and passes the value in:

FROM node:22-slim AS build

WORKDIR /app

# Opt in to the Railway variable for this build stage
ARG NPM_TOKEN

COPY package.json package-lock.json ./
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
    && npm ci \
    && rm .npmrc

COPY . .
RUN npm run build

FROM node:22-slim

WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./

CMD ["node", "dist/server.js"]

Two rules:

  1. ARG is scoped to a build stage. In a multi-stage build, declare the ARG inside the stage that uses it. An ARG declared before the first FROM is not visible inside any stage unless you redeclare it.
  2. ARG values do not persist into the running container. Unlike ENV, an ARG disappears when the build finishes. That is exactly what you want for build-only secrets.
FROM node:22-slim

# Correct: declared inside the stage that needs it
ARG RAILWAY_ENVIRONMENT
RUN echo "Building for ${RAILWAY_ENVIRONMENT}"

Keep secrets out of the final image

Docker image layers are an archive of everything that happened during the build. That's why a secret written to a file in one RUN instruction and deleted in a later one still exists in the earlier layer. The same holds for ARG values used in RUN commands: they're recorded in image metadata too, where docker history can show them.

To limit exposure:

  • Use multi-stage builds. Do secret-dependent work (dependency installation, private git clones) in an early stage, then COPY --from only the artifacts you need into the final stage. Layers from earlier stages are not shipped with the final image.
  • Create and remove the secret in a single RUN. Writing and deleting .npmrc in one instruction, as in the example above, keeps the token out of any committed layer in that stage.
  • Never promote a secret with ENV. ENV NPM_TOKEN=${NPM_TOKEN} stores the value in the image config permanently. Use ENV only for non-sensitive configuration.

Runtime secrets need no Dockerfile changes

Runtime secrets should not appear in your Dockerfile at all. Instead, Railway injects every service variable into the container environment when it starts, and your application reads them directly:

// server.js
const http = require("http");

const port = process.env.PORT || 3000;
const databaseUrl = process.env.DATABASE_URL;

if (!databaseUrl) {
  console.error("DATABASE_URL is not set");
  process.exit(1);
}

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ status: "ok" }));
});

server.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Nothing needs to be declared in the Dockerfile, and nothing is baked into the image. This also means you can change a runtime secret and redeploy without rebuilding the image, and the same image works across environments because each environment supplies its own variable values.

Volumes are not available at build time

A common mistake is trying to read or write persistent data during the build. Volumes, though, are mounted to your service's container only when it starts, not during the build, so anything written to the mount path in a RUN instruction lands in the image layer instead of the volume, and won't persist.

Volumes are also not mounted during the pre-deploy command. If a step needs volume data, run it as part of the start command.

The same logic applies to secrets stored as files: you cannot stage a credentials file on a volume and expect the build to read it. Pass build credentials through ARG instead.

Sealed variables work in both phases

If a secret should never be readable again after you set it, seal it. Sealed variables are provided to builds and deployments like any other variable, but their values can never be viewed in the dashboard or retrieved through the API afterward. Sealing changes visibility, not availability: a sealed NPM_TOKEN still flows into a Dockerfile ARG at build time.

Note the constraints: sealed variables cannot be un-sealed, and they are not copied when duplicating an environment or service.

Quick reference

NeedMechanismWhere it lives
Private package install during buildARG in the build stageDiscarded after build
Value your app reads while runningService variable, read from process envInjected at container start
Non-sensitive config baked into imageENVStored in image config
Persistent filesVolumeMounted at container start, never during build

Common failure modes

Variable is empty during the build. You did not declare a matching ARG, or you declared it in a different build stage. Add ARG MY_VAR to the stage that uses it.

Secret visible in the image. You used ENV for a secret, or wrote it to a file and deleted it in a separate RUN instruction. Move the secret handling into a single RUN inside an early stage of a multi-stage build.

Changed a variable but the app still sees the old value. If the value was consumed at build time, it is frozen in the image until the next build. Deploy again to rebuild. Runtime variables do not have this problem; they are injected fresh at every start.

Build writes to a volume path but data is missing at runtime. Expected behavior. Volumes are attached only to the running container. Generate the data in your start command instead.

Next steps