Optimizing Your Dockerfile for a Production REACT.js Application. Best practices!
Lear how to build production ready Dockerfile for React.js 19
In this guide, we’ll create a production-ready Dockerfile for a React.js app. We’ll explore best practices that enhance security, efficiency, and performance, ultimately ensuring that your Docker container is optimized for production environments.
You can find working React.js 19 example, following best practices here:
frontend-prod-dockerfiles/react.js at main · kristiyan-velkov/frontend-prod-dockerfiles
Why Optimize Your Dockerfile?
A well-optimized Dockerfile results in smaller images, faster builds, and more secure containers. This guide covers techniques like multi-stage builds, dependency caching, and best practices for reducing image size, making your application deployment-ready.
Project Directory Structure
To keep things organized, let’s start with a clear directory structure. This setup helps separate code from configuration, improving readability and maintainability.
react.js - App
├── app # React.js app code
├── Dockerfile # Production Dockerfile
├── .dockerignore # Exclude files from Docker build context
├── .env # Environment variables for production
└── ...
With this structure, we’ll use a Dockerfile and .dockerignore
file to efficiently package only what’s needed for production. We will use for this example npm as a package manager.
React.js production Dockerfile
In the root of your project, create a Dockerfile.
# =========================================
# Stage 1: Build the React.js Application
# =========================================
ARG NODE_VERSION=22.14.0-alpine
ARG NGINX_VERSION=alpine3.21
# Use a lightweight Node.js image for building (customizable via ARG)
FROM node:${NODE_VERSION} AS builder
# Set the working directory inside the container
WORKDIR /app
# Copy package-related files first to leverage Docker's caching mechanism
COPY --link package.json package-lock.json ./
# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN npm ci
# Copy the rest of the application source code into the container
COPY --link . .
# Build the React.js application (outputs to /app/dist)
RUN npm run build
# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================
FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner
# Use a built-in non-root user for security best practices
USER nginx
# Copy custom Nginx config
COPY --link nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --link --from=builder /app/dist /usr/share/nginx/html
# Expose port 8080 to allow HTTP traffic
# Note: The default NGINX container now listens on port 8080 instead of 80
EXPOSE 8080
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
In the root of your project (where your Dockerfile is located), create a .dockerignore
file to exclude unnecessary files and directories from being copied into the Docker image:
# Ignore dependencies and build output
node_modules/
dist/
out/
.tmp/
.cache/
# Ignore Vite, Webpack, and React-specific build artifacts
.vite/
.vitepress/
.eslintcache
.npm/
coverage/
jest/
cypress/
cypress/screenshots/
cypress/videos/
reports/
# Ignore environment and config files (sensitive data)
*.env*
!.env.production # Allow production env file if needed
*.log
# Ignore TypeScript build artifacts (if using TypeScript)
*.tsbuildinfo
# Ignore lockfiles (optional if using Docker for package installation)
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Ignore local development files
.git/
.gitignore
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Ignore Docker-related files (to avoid copying unnecessary configs)
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.override.yml
# Ignore build-specific cache files
*.DS_Store
*.lock
Step 1: Creating the Dockerfile
To make the Dockerfile efficient, we’ll use multi-stage builds to handle dependencies, app compilation, and a final stage for the production image.
# =========================================
# Stage 1: Build the React.js Application
# =========================================
ARG NODE_VERSION=22.14.0-alpine
ARG NGINX_VERSION=alpine3.21
# Use a lightweight Node.js image for building (customizable via ARG)
FROM node:${NODE_VERSION} AS builder
# Set the working directory inside the container
WORKDIR /app
Explanation:
Base Image: We’re using a specific slim Node.js version (22.14.0-alpine). Specifying an exact version ensures consistency, preventing unexpected issues if the Node.js image changes in the future. Using a slim version reduces image size, which optimizes build times and resource usage. We avoid
alpine
here because Alpine-based images, while smaller, sometimes introduce compatibility issues when installing native dependencies. The Alpine package manager (APK) differs from Debian’s APT, which is often used in Node.js projects, makingslim
versions generally more compatible for complex applications.Working Directory: Setting
/app
as the working directory keeps our Docker commands organized and prevents path issues within the container, making the Dockerfile cleaner and reducing potential errors when navigating file paths.
Learn more:
Step 2: Install Dependencies
The next stage is dedicated to installing dependencies. We’ll cache the dependencies layer to speed up builds and make rebuilding the image more efficient.
# Copy package-related files first to leverage Docker's caching mechanism
COPY --link package.json package-lock.json ./
# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN npm ci
Explanation:
Dependency Stage: By isolating dependencies in the
builder
stage, Docker caches this layer. This setup is efficient because even if you change the application code, Docker will reuse this cached layer unless the dependencies listed inpackage.json
change. As a result, builds are faster since dependencies don't need to be reinstalled every time the source code is updated.
Learn more:
npm ci - https://docs.npmjs.com/cli/v8/commands/npm-ci
Step 3: Build the React.js Application
In this stage, we copy the dependencies and source code into the container and then build the React.js app for production.
# Copy the rest of the application source code into the container
COPY --link . .
# Build the React.js application (outputs to /app/dist)
RUN npm run build
# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================
Multi-Stage Builds: Using a separate build stage,
builder
, allows us to compile the application without carrying over any unnecessary files or dependencies. Only the final build output will be passed to the production image, keeping the final image size small and focused.Efficient Builds: Running
npm run build
compiles the React.js app and generates optimized, production-ready code. By using theCOPY --from=builder
command, we bring only thenode_modules
folder from thebuilder
stage, ensuring all dependencies are available without re-installing them. This also keeps our build efficient and organized.
Learn more:
Step 4: Prepare the Production Image
The final stage creates a minimal, secure production image, copying only what’s required to run the app.
FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner
# Use a built-in non-root user for security best practices
USER nginx
# Copy custom Nginx config
COPY --link nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --link --from=builder /app/dist /usr/share/nginx/html
# Expose port 8080 to allow HTTP traffic
# Note: The default NGINX container now listens on port 8080 instead of 80
EXPOSE 8080
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
Explanation:
Minimal Production Image
This stage only includes essential files, like the compiled dist
build output and public assets. Excluding development files, dependencies, and source code reduces the final image size and improves load time. Additionally, this approach limits exposure to unnecessary files, enhancing security.
Non-Root User for Security
The image runs as the built-in
nginx
user, following security best practices.Running the application as a non-root user minimizes risk if the container is compromised, as it restricts permissions and potential damage.
Using Nginx for Static Files
A custom Nginx configuration (
nginx.conf
) is copied to control routing and caching.The
dist
folder, containing the prebuilt static files, is copied to/usr/share/nginx/html
, which is Nginx’s default serving directory.Nginx listens on port
8080
, following best practices for unprivileged containers.
Optimized EntryPoint and CMD
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
ensures Nginx starts with the custom configuration.CMD ["-g", "daemon off;"]
runs Nginx in the foreground, ensuring proper container behavior.
Learn more:
Step 5: Using a .dockerignore
File
A .dockerignore
file excludes unnecessary files from the Docker build context. This speeds up the build process and minimizes the final image size by reducing the amount of data Docker needs to copy into the container.
Here’s a recommended .dockerignore
file:
# Ignore dependencies and build output
node_modules/
dist/
out/
.tmp/
.cache/
# Ignore Vite, Webpack, and React-specific build artifacts
.vite/
.vitepress/
.eslintcache
.npm/
coverage/
jest/
cypress/
cypress/screenshots/
cypress/videos/
reports/
# Ignore environment and config files (sensitive data)
*.env*
!.env.production # Allow production env file if needed
*.log
# Ignore TypeScript build artifacts (if using TypeScript)
*.tsbuildinfo
# Ignore lockfiles (optional if using Docker for package installation)
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Ignore local development files
.git/
.gitignore
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Ignore Docker-related files (to avoid copying unnecessary configs)
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.override.yml
# Ignore build-specific cache files
*.DS_Store
*.lock
Explanation:
node_modules and .next: The
node_modules
and.next
directories are excluded to avoid copying them from your local environment. These will be recreated in the Docker build, keeping the image clean and consistent with production dependencies only.Environment Files: Only include production-related
.env
files in the build. Excluding development and test environment files reduces the risk of exposing sensitive data and simplifies the final image.Local Files: Files like
.git
,README.md
, andDockerfile
are not needed in the production container. By excluding these, you reduce the image size and avoid unnecessary clutter.
Summary of Best Practices
Use Multi-Stage Builds: Separating dependency installation, build, and production stages keeps the image lean, efficient, and free of unnecessary files.
Install Only Production Dependencies: Setting
NODE_ENV=production
and usingnpm ci --production
ensures you only include essential packages, reducing image size and potential security risks.Optimize the Build Context: A well-configured
.dockerignore
file minimizes the Docker build context, speeding up the build and reducing the final image size by excluding unnecessary files.Run as a Non-Root User: For added security, always configure the app to run as a non-root user in production. This limits the potential damage in case of security vulnerabilities.
Conclusion
Following these steps, you can create a secure, efficient, and production-ready Dockerfile for your React.js application. Multi-stage builds, optimized dependency installation, and a well-configured .dockerignore
file are essential practices for lean, scalable Docker images. With these best practices, your React.js app will be ready for production deployment.
What are your go-to tips for Dockerizing Node.js applications?
Share your insights in the comments below!