Points where I got stuck when referencing Next.js environment variables on ECS

Points where I got stuck when referencing Next.js environment variables on ECS

2025.09.05

Introduction

Hello everyone, I'm Asano from the Cloud Business Division Consulting Department.

I hadn't tried hosting Next.js on ECS before, so I gave it a try and encountered several issues related to environment variable references, which I'd like to summarize here.

As a note, the stumbling points below are not specific to ECS; they just happened to occur on ECS in this case.

Operating Environment

Main Framework

  • Next.js: 15.5.2
  • Build option: Turbopack
  • React: 19.1.0

Docker Environment

  • Base image: node:20-alpine

  • Execution method: Server mode (non-standalone)## ① Browser environment variables are inlined at build time
    Next.js handles two types of environment variables:

    1. Environment variables accessible only on the server side: Variables without the "NEXT_PUBLIC_" prefix are only available in the Node.js environment and cannot be accessed from the browser.
    1. Browser environment variables: Variables with the "NEXT_PUBLIC_" prefix can be used from both server-side and browser-side. Be careful not to include highly confidential information as users can view these values if they want to.

For browser environment variables (type 2), the values are inlined from dynamic values (process.env.NEXT_PUBLIC_HOGEHOGE) to static values in the JS bundle delivered to the client at build time, so we need to pass environment variables at the "npm run build" stage. Therefore, even if you set "NEXT_PUBLIC_HOGEHOGE" in your ECS environment variables, it will be evaluated as undefined in your code because that information isn't available when building in the Dockerfile.

Here's a specific example.
The Dockerfile doesn't pass environment variables during the build process.
So even if you set environment variables in the task definition with CDK as shown below, it doesn't matter because they've already been converted to static values during the build.

Dockerfile
			
			# Base image
FROM node:20-alpine

RUN apk add --no-cache curl netcat-openbsd bind-tools

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm install

COPY . .

RUN npm run build

EXPOSE 80

# Start command
CMD ["npm", "start"]


		
cdkProject
			
			// Task definition
const frontendTaskDef = new ecs.FargateTaskDefinition(this, 'FrontendTaskDef', {
    memoryLimitMiB: 512,
    cpu: 256,
    executionRole: taskExecutionRole,
    taskRole: taskRole
});

frontendTaskDef.addContainer('frontend-container', {
    image: ecs.ContainerImage.fromRegistry(`${cdk.Aws.ACCOUNT_ID}.dkr.ecr.${cdk.Aws.REGION}.amazonaws.com/${props.frontendRepo}:latest`),
    memoryLimitMiB: 512,
    portMappings: [
    {
        containerPort: 80,
        protocol: ecs.Protocol.TCP
    }
    ],
    environment: {
        'NODE_ENV': 'production',
        'PORT': '80',
        'NEXT_PUBLIC_CLIENT_URL': 'https://hogehoge.com' // Defining browser environment variable
    },
    logging: ecs.LogDrivers.awsLogs({
        logGroup: props.frontendLogGroup,
        streamPrefix: 'frontend'
    })
});

		

Even if you write the following in your code, the value of {process.env.NEXT_PUBLIC_CLIENT_URL} will be empty.

Next.js ClientComponent
			
			'use client'
export default function ClientPage() {
  return (
    <div className="mt-8 p-4 bg-green-50 border border-green-200 rounded-lg">
        <h3 className="text-lg font-semibold text-green-800 mb-2">クライアント側環境変数テスト</h3>
        <div className="bg-green-100 p-3 rounded">
            <p className="text-green-700 mb-1"><strong>NEXT_PUBLIC_CLIENT_URL:</strong></p>
            <p className="text-green-800 font-mono text-sm">
                {process.env.NEXT_PUBLIC_CLIENT_URL}
            </p>
        </div>
    </div>
  )
}
```Actual Display

![alt text](https://devio2024-2-media.developers.io/upload/6JBVXh4WvoaBzK4PVYuLGh/2025-09-05/PkRQ20t7dt4H.png)

Other articles and the official documentation also mention inlining. It's important to check things properly.

Official Documentation "Environment Variables"
https://nextjsjp.org/docs/app/guides/environment-variables

### Solutions
#### Pass environment variables in the Dockerfile
Among various solutions, simply passing values to `.env.production` in your Dockerfile will allow proper inlining with appropriate values during the build.
You could also fetch values from ssm parameter store or similar services and pass them.

```Dockerfile:Dockerfile
# Create .env.production during build (example)
RUN echo "NEXT_PUBLIC_CLIENT_URL=https://hogehoge.com" >> .env.production

		

Make it a variable for dynamic lookup

Directly quoting the official documentation, dynamic lookup values are excluded from build-time inlining. By crafting your code as shown below, you can avoid passing values at build time and have them evaluated at runtime instead.

Next.js
			
			// This won't be inlined because it uses a variable
const varName = 'NEXT_PUBLIC_ANALYTICS_ID'
setupAnalyticsService(process.env[varName])

// This won't be inlined because it uses a variable
const env = process.env
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)

		

② Server Components in "Static Rendering" mode will also inline server-side environment variables at build time

The phenomenon described above can also occur with environment variables that don't include "NEXT_PUBLIC_".
The default rendering mode for Server Components when using SSR is "Static Rendering", so code like the following has no effect as it gets compiled into static HTML files at build time:

Next.js ServerComponent
			
			// Example in Static Rendering (inlined at build time)
'use server'

export default function StaticServerComponent() {
    // Value gets inlined at build time
    const apiUrl = process.env.API_BASE_URL // At build time: undefined 

    return (
        <div>
            <p>API URL: {apiUrl}</p> {/* Always "undefined" */}
        </div>
    )
}

		

Official Documentation "Server Components"
https://nextjs.org/docs/14/app/building-your-application/rendering/server-components

Solution#### Changing to Dynamic Rendering

When using environment variables in Server Components, you should explicitly switch from "Static Rendering" to "Dynamic Rendering".
This can be forced by using the following dynamic functions:

  • cookies()
  • headers()
  • noStore()

Example:

Next.js ServerComponent
			
			// Force Server Component to use Dynamic Rendering
import { unstable_noStore as noStore } from 'next/cache'

// Server Component - direct Django API call
export default async function ServerComponentTest() {
  // Force Dynamic Rendering (prevents execution at build time)
  noStore()

  // Environment variables will now be processed at runtime during the request
  const serverComponentApiUrl = process.env.API_BASE_URL

  .
  .
  .
}

		

Changing to Dynamic Rendering means it's no longer a completely static file, so the cache efficiency decreases slightly, but you can still update only the data that needs to be refreshed with each request while leveraging the cache for everything else, thus maintaining the benefits of using Server Components.

In Conclusion

Since Next.js processes code differently depending on whether it's CSR, SSR, ISR, etc., if you don't properly understand the processing order and specifications, you'll run into problems like this one.
The key point in this issue—"being inlined at build time"—isn't limited to environment variables. I realized it could potentially lead to other problems, like when you want to output dynamic values but only get fixed values.

When you understand the specifications, it makes perfect sense, but if you proceed based on intuition, you might end up wondering "why isn't this displaying?" So make sure to thoroughly check the official documentation! That's all for today.

Share this article

FacebookHatena blogX

Related articles