Build-time vs Run-time secrets in Docker — and how to pass them safely

In this article, we’ll explore how to securely pass build-time and run-time secrets to Docker containers in both development and production environments.
Before we jump into the implementation, let’s understand the difference between build-time and run-time secrets.
Find github repo for this here
Build time secrets
Build-time secrets are values your application needs during the build process, not during runtime.
For example, imagine you're building a blog application that fetches blog data from a database and generates static pages at build time. In this case, you’ll need to provide the database URL while the app is building.
Another example could be using an email service that requires public API keys to preload templates. These keys also need to be available during the build step.
Run time secrets
Run-time secrets are needed when your application is up and running.
For instance, your Stripe private key is not required during the build phase. It is only needed when a user initiates a payment. Other examples include environment-specific credentials like JWT secrets or third-party service tokens that are only accessed during the actual execution of the app.
Now that we have a clear understanding, let's move on to how you can securely pass these secrets into Docker containers across different environments.
In this article I am using a NextJS application with Postgres to demonstrate how to pass build-time and run-time secrets securely. But the same can be applied to any application that requires build-time and run-time secrets.
Setting Up Prisma in a Next.js App (Optional)
Feel free to skip this section if you're only interested in running the demo via Docker. You can clone the GitHub repo and spin it up directly using docker.
Let’s start by creating a simple Next.js app and integrating it with a PostgreSQL database using Prisma.
Step 1: Create a Next.js App
npx create-next-app@latest
Once your app is ready, install Postgres locally or use a managed service like Supabase or Railway. If you want a local DB instance for development:
npm install postgres --save-dev
(You can also use Docker to run a Postgres container if preferred.)
Step 2: Initialize Prisma
Install Prisma CLI:
npm install prisma --save-dev
Then initialize Prisma:
npx prisma init
This will create a prisma/
folder with a schema.prisma
file inside. It should look like this:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
Step 3: Define a Model
Below the datasource
block, add a simple Post
model to test things out:
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
}
Step 4: Setup Environment and Migrate
Make sure your PostgreSQL database is running and you've updated the DATABASE_URL
in your .env
file.
Then, apply the migration:
npx prisma migrate dev --name init
Also, generate the Prisma client:
npx prisma generate
Step 5: Create a Singleton Prisma Client
Install the Prisma Client package:
npm install @prisma/client
Create a new file: src/lib/prisma.ts
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()
This ensures a single Prisma instance is reused across hot reloads in development.
Step 6: Add DB queries
Update src/app/page.tsx
with:
import { prisma } from "@/lib/prisma";
export default async function Home() {
await prisma.post.create({
data: {
title: "Hello " + Math.random(),
content: "Hi there",
},
});
const posts = await prisma.post.findMany();
return (
<div>
<pre>{JSON.stringify(posts, null, 2)}</pre>
</div>
);
}
Note:
Home
is declared as an async function because we are using server-side data fetching with Prisma.
Let’s move to the Docker implementation now.
Dockerizing the App
Before we jump into writing a secure Dockerfile
, let's first understand why a basic Dockerfile
might fail when secrets are required at build time.
Start by creating a Dockerfile
in the root of your project and add the following code:
FROM node:23-alpine
WORKDIR /app
COPY ./package.json ./package.json
COPY ./package-lock.json ./package-lock.json
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD [ "npm", "run", "start" ]
This is a simple Dockerfile that:
Copies your app files into the container
Installs dependencies
Builds the app
Starts the production server
Also, don’t forget to create a .dockerignore
file to exclude sensitive files like .env
and unnecessary folders like node_modules
:
# .dockerignore
.env
node_modules
Now, build your Docker image using:
docker build -t next-app .
You'll likely encounter an error that ends with something like this:
> Build error occurred
[Error: Failed to collect page data for /]
------
Dockerfile:12
--------------------
...
RUN npm run build
...
--------------------
ERROR: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
This confirms that the basic Dockerfile fails when your application requires secrets like environment variables during build time.
Passing Build-Time Secrets
To securely pass secrets during the build phase, Docker provides a feature called Build Secrets.
Build Secrets ensure that sensitive data like API keys or database URLs can be accessed securely during the build process without being saved in image layers.
This is a recommended approach for passing private credentials that your app needs at build time.
Learn more: Docker Build Secrets
Now, update your Dockerfile like this
# syntax=docker/dockerfile:1.4
FROM node:23-alpine
WORKDIR /app
COPY ./package.json ./package.json
COPY ./package-lock.json ./package-lock.json
RUN npm install
COPY . .
RUN --mount=type=secret,id=database_url \
export DATABASE_URL=$(cat /run/secrets/database_url) \
npm run build
EXPOSE 3000
CMD [ "npm", "run", "start" ]
There are two new additions in this Dockerfile compared to the basic version we saw earlier:
1. # syntax=docker/dockerfile:1.4
This line specifies the Dockerfile syntax version. By default, Docker uses an older syntax that doesn’t support advanced features like build-time secrets. By explicitly setting version 1.4
, we unlock access to features like the RUN --mount=type=secret
instruction, which we use to safely pass secrets during build time.
2. RUN --mount=type=secret,id=database_url \ ...
This command securely passes the DATABASE_URL
secret to the build process. Let’s break it down:
--mount=type=secret,id=database_url
tells Docker to mount a secret with the IDdatabase_url
at build time.cat /run/secrets/database_url
reads the secret file from the mounted path.export DATABASE_URL=$(...)
sets the environment variable using the value from the secret.npm run build
then uses this environment variable during the build process.
Prepare for build
Now, before we build the Docker image, let’s take care of one important step.
Inside your project’s root directory, create a folder named env
. Inside that folder, create a file called database_url.txt
and paste your database URL into it. It should look something like this:
(I will add the explanation of this part while explaining the build command. For now, just follow together.)
postgresql://neondb_owner:npg_YZ9dFnE8QBxz@ep-soft-field-a1m23wcs-pooler.ap-southeast-1.aws.neon.tech/neondb?sslmode=require
(Don’t worry, I’m going to delete this DB URL after writing this blog 😄)
Also, make sure to add env/
to both your .gitignore
and .dockerignore
files because you don’t want this sensitive information to be committed to version control or included in your Docker context.
Now that we're set up, it’s time to build the Docker image. Here’s the command you’ll use:
DOCKER_BUILDKIT=1 docker build --secret id=database_url,src=env/database_url.txt -t next-app .
Let’s break it down:
DOCKER_BUILDKIT=1
This enables Docker BuildKit, a modern builder with advanced features like secret mounting during builds (which we are using).
Without this, you won’t be able to use the --secret
flag in the build command.
docker build
This is the core command to build your Docker image.
--secret id=database_url,src=env/database_url.txt
This is the most important part. Here’s what it does:
id=database_url
: This is the identifier for the secret. It must match the ID you use in your Dockerfile (--mount=type=secret,id=database_url
).src=env/database_url.txt
: This points to the actual file containing your secret, and in our case it is thedatabase_url.txt
(Remember we created env/database_url.txt? Here is the use of that file)
This securely mounts the secret during the build process without including it in the final image.
-t next-app
This tags the built image as next-app
, so you can reference it easily later.
.
(dot)
This sets the build context to the current directory, basically telling Docker where to find the Dockerfile and your app’s code.
With all that done, your image will be built with your database secret securely passed in during the build. Nice and clean
So, with this we have covered the most difficult part of this tutorial; now everything from here will be very easy and straightforward.
Running the application
Finally run your container with command
docker run --name next-app -p 3000:3000 next-app
This will start your NextJS app inside the Docker container and will map to port 3000 of your local machine. Now, go to http://localhost:3000 of your machine and you will see something like this there
With this, we have learned how to pass build secrets to the Docker container; let’s move on to run-time secrets.
Runtime Secrets
Passing build-time secrets was a bit tricky and required some extra steps. But when it comes to runtime secrets, things are much simpler. All you need to do is slightly modify your docker run
command.
To pass runtime secrets, run the following command:
docker run --env-file .env --name next-app -p 3000:3000 next-app
Let’s break this down:
docker run
: Starts a Docker container.--env-file .env
: Specifies the path to the.env
file that contains your environment variables. This file should be present in your project’s root directory.
If your.env
file is located somewhere else (e.g., inside asrc
folder), adjust the path accordingly:--env-file ./src/.env
--name next-app
: Gives the container a name, in this case, "next-app".-p 3000:3000
: Maps port 3000 of your local machine to port 3000 of the container.next-app
: Refers to the Docker image you built earlier.
With this command, your container will start up with the environment variables from the .env
file injected at runtime.
How to Pass Secrets in Production
Now that we’ve learned how to pass both build-time and runtime secrets during development, let’s quickly look at how you might do this in production.
Build-time secrets: You can use GitHub Secrets in your CI/CD workflows to securely pass sensitive values during the build stage.
Runtime secrets: Simply create the
.env
file manually on your virtual machine (e.g., EC2, DigitalOcean, etc.) before running your container.
This is just one approach and there are several other ways to handle secrets securely in production, such as using secret management tools like AWS Secrets Manager, HashiCorp Vault, or Docker Swarm secrets.
I’ve kept this section brief since implementation can vary based on your deployment setup and preferences.
If you enjoyed this blog, consider following me on X (https://x.com/_devkunal) where I share my daily learnings and dev tips!
Subscribe to my newsletter
Read articles from Kunal Nasa directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Kunal Nasa
Kunal Nasa
Hey, I’m a full-stack developer who enjoys learning things from scratch. I’m currently pursuing my bachelor’s in technology in India. I’m especially curious about databases, parsers, and a bit about operating systems too. Right now, I’m learning DevOps stuff and how to spin up multiple servers efficiently around the world to handle 5 users on my app xD. Always happy to share and learn in public.