Frontend

Dockerizing Next.js: The Hidden Challenges

Dockerizing Next.js: The Hidden Challenges

I wanted to write this article for a month. There is a big difference between a small project on Vercel and a real production app. In Vercel, everything is easy and feels "pink-colored" because the complexity is hidden.

When your project grows, you need more control. You move from a simple setup to a large infrastructure with multiple pods and scaling rules. I use Google Cloud for my projects, but these challenges are the same if you use AWS or Azure.

This move is a big change in architecture. In Vercel, the system is managed for you. Outside of Vercel, you are the architect. In this article, I want to share the first stumble I had when dockerizing Next.js. I never thought it would give me problems, but it did: Environment Variables.

Vercel vs. Self-Managed Architecture

When you use Vercel, they provide a "black box" that handles your build, your deployment, your SSL, and your CDN. When you move to your own infrastructure (like Docker on GCP), you need to replace each piece of that box.

graph TD
    subgraph "The Vercel Way (Managed)"
        A[Git Push] --> B[Vercel Build Engine]
        B --> C[Vercel Edge Network]
        C --> D[User]
    end
 
    subgraph "The Self-Managed Way (Docker)"
        E[Git Push] --> F[CI/CD Pipeline\nGitHub Actions/GitLab]
        F -->|Build Image| G[Container Registry\nArtifact Registry/ECR]
        G -->|Deploy| H[Cloud Hosting\nCloud Run/App Runner]
        I[Secret Manager] -.->|Runtime Secrets| H
        H --> J[Load Balancer / CDN]
        J --> K[User]
    end

To make this architecture work, the most critical part is the Build Pipeline. This is where your code is converted into a Docker image, and it is exactly where most developers face their first big problem: Environment Variables.

The Technical Wall: Environment Variables

Before we build our architecture, we must understand how Next.js treats environment variables. It puts them into two separate groups: Build-Time Variables and Runtime Variables. In Vercel, this connection is seamless. You add your variables to the dashboard and they work in both cases. In your own architecture, you must handle this manually in your Dockerfile and your CI/CD.

Build-Time Variables (NEXT_PUBLIC_)

Variables starting with NEXT_PUBLIC_ are baked into the final JavaScript bundle when you run npm run build. They are visible to the browser.

Example: API URLs or analytics IDs.

If you forget to add these variables during the build step, the final JavaScript will have undefined or empty strings. This happens even if you try to add them later when starting the app.

Runtime Variables (Private)

Variables without the prefix are private and only exist on the server. They are not bundled during build; instead, they are accessed during request time.

Example: Database URLs, API tokens, or payment keys.

The Scenario

Imagine an app that needs to fetch data from an external API. In local development, everything works with no effort. You create a .env.local file and Next.js picks up the values automatically:

# .env.local
NEXT_PUBLIC_API_URL=https://dummyjson.com/products
API_SECRET_KEY=my-super-secret-key-123
  • NEXT_PUBLIC_API_URL: This is for the frontend. It tells your app where to fetch information.
  • API_SECRET_KEY: This is a private key for your backend logic.

When you run npm run dev, Next.js reads these variables and your app works perfectly. In your code, you access them like this:

// Frontend: This will be part of the JS bundle
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
 
// Backend: This will stay only on the server
const secret = process.env.API_SECRET_KEY;

This "magic" happens because Next.js is designed to make local development simple. But as soon as you move to Docker, this magic disappears.

Moving to Docker

After testing the app locally, you might think that moving to Docker is simple. If npm run dev and npm run build work on your machine, the Docker image should work too.

I made my first Dockerfile like this:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build # ❌ NEXT_PUBLIC_ variables are missing!
CMD ["npm", "start"]

I built the image, started the container, and found my first stumble. The app was running, but the frontend variables were missing. This is because Next.js needs NEXT_PUBLIC_ variables during the build process. In my simple Dockerfile, the npm run build command had no access to these variables.

The result? The compiled files were created with undefined values.

The Solution: ARG and ENV

To fix this, we need to pass these variables during the build phase using Docker ARG and ENV.

  • ARG (Build Argument): This is a temporary variable used only while the image is being built.
  • ENV (Environment Variable): This is a permanent variable inside the container.

Here is the correct way to write the Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
 
# 1. Capture build-time args
ARG NEXT_PUBLIC_API_URL
# 2. Assign to ENV for the build process
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
 
RUN npm run build
CMD ["npm", "start"]

Now you can build the image with the correct values:

docker build --build-arg NEXT_PUBLIC_API_URL="https://api.com" -t my-app .

What about private variables?

Private variables (without the NEXT_PUBLIC_ prefix) are different. You should never put them in the Dockerfile. These variables are used when the server is running, not when you build the image.

You can inject them when you start the container:

docker run -e API_SECRET_KEY="my-secret" my-app

Security Concerns

When you move your app to a production environment, security is very important. In Vercel, you add your secrets in the dashboard and they are encrypted automatically.

In your own infrastructure, you might think about using the -e API_SECRET_KEY=... flag when you start Docker. This is not a good idea for a real project. It is not secure and it is not scalable. Typing your passwords in plain text in the terminal is a big risk.

Instead, you should use a Secret Manager. Big cloud providers like Google Cloud, AWS, and Azure provide services to manage your secrets safely.

When your container starts, the cloud provider injects these secrets directly into the environment. This is a much better way to work because your secrets are never saved in your CI/CD logs, your terminal history, or your Docker image layers. This keeps your application safe as it grows to multiple pods and complex setups.

Recap

Now that our Docker image is ready, we can move it to any cloud provider like Google Cloud, AWS, or DigitalOcean. Choosing the right architecture is a big step when you move beyond Vercel or your local machine.

In a real production environment, you might use a more complex setup with many pods and Kubernetes. I wanted to focus on this first "stumble" because it is the most common reason why Next.js deployments fail.

In a future article, I will explain the next step: how to upload this image to Google Cloud and how to use their Secret Manager to handle your enterprise secrets.

I hope this guide helps you when you move your application from Vercel to a real Cloud environment.

Happy coding!


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.

Discussion