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

Kunal NasaKunal Nasa
9 min read

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.

Docker 1.0 brings container technology to the enterprise | ZDNET

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 ID database_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 the database_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 a src 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!

1
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.