Handle Build-Time vs Runtime Secrets in Docker Builds
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
ARGinstruction.
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:
ARGis scoped to a build stage. In a multi-stage build, declare theARGinside the stage that uses it. AnARGdeclared before the firstFROMis not visible inside any stage unless you redeclare it.ARGvalues do not persist into the running container. UnlikeENV, anARGdisappears 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 --fromonly 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.npmrcin 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. UseENVonly 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
| Need | Mechanism | Where it lives |
|---|---|---|
| Private package install during build | ARG in the build stage | Discarded after build |
| Value your app reads while running | Service variable, read from process env | Injected at container start |
| Non-sensitive config baked into image | ENV | Stored in image config |
| Persistent files | Volume | Mounted 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
- Dockerfiles on Railway covers custom Dockerfile paths, cache mounts, and build-time variables.
- Using Variables explains service, shared, reference, and sealed variables.
- Managing Secrets on Railway walks through choosing the right variable scope for each secret.
- Frontend Environment Variables covers the build-time vs runtime split in Vite, Next.js, and other frontend frameworks.