If you’re anything like me, you love crafting sleek and responsive user interfaces with React. But, setting up consistent development environments and ensuring smooth deployments can also get complicated. That’s where Docker can help save the day.
As a Senior DevOps Engineer and Docker Captain, I’ve navigated the seas of containerization and witnessed firsthand how Docker can revolutionize your workflow. In this guide, I’ll share how you can dockerize a React app to streamline your development process, eliminate those pesky “it works on my machine” problems, and impress your colleagues with seamless deployments.
Let’s dive into the world of Docker and React!

Why containerize your React application?
You might be wondering, “Why should I bother containerizing my React app?” Great question! Containerization offers several compelling benefits that can elevate your development and deployment game, such as:
- Streamlined CI/CD pipelines: By packaging your React app into a Docker container, you create a consistent environment from development to production. This consistency simplifies continuous integration and continuous deployment (CI/CD) pipelines, reducing the risk of environment-specific issues during builds and deployments.
- Simplified dependency management: Docker encapsulates all your app’s dependencies within the container. This means you won’t have to deal with the infamous “works on my machine” dilemma anymore. Every team member and deployment environment uses the same setup, ensuring smooth collaboration.
- Better resource management: Containers are lightweight and efficient. Unlike virtual machines, Docker containers share the host system’s kernel, which means you can run more containers on the same hardware. This efficiency is crucial when scaling applications or managing resources in a production environment.
- Isolated environment without conflict: Docker provides isolated environments for your applications. This isolation prevents conflicts between different projects’ dependencies or configurations on the same machine. You can run multiple applications, each with its own set of dependencies, without them stepping on each other’s toes.
Getting started with React and Docker
Before we go further, let’s make sure you have everything you need to start containerizing your React app.
Tools you’ll need
- Docker Desktop: Download and install it from the official Docker website.
- Node.js and npm: Grab them from the Node.js official site.
- React app: Use an existing project or create a new one using
create-react-app.
A quick introduction to Docker
Docker offers a comprehensive suite of enterprise-ready tools, cloud services, trusted content, and a collaborative community that helps streamline workflows and maximize development efficiency. The Docker productivity platform allows developers to package applications into containers — standardized units that include everything the software needs to run. Containers ensure that your application runs the same, regardless of where it’s deployed.
How to dockerize your React project
Now let’s get down to business. We’ll go through the process step by step and, by the end, you’ll have your React app running inside a Docker container.
Step 1: Set up the React app
If you already have a React app, you can skip this step. If not, let’s create one:
npm create vite@latest my-react-app -- --template react-ts
This command creates a new React + TypeScript application inside a folder named my-react-app. It installs the necessary npm packages and starts the development server, which will be available at http://localhost:5173.
Vite has become the standard choice for React projects because it’s significantly faster, more lightweight, and easier to use compared to the old Create React App (CRA), which is no longer actively maintained.
Step 2: Create a Dockerfile
In the root directory of your project, create the Dockerfiles that define how your React app will run inside containers. We’ll use two separate Dockerfiles:
- Dockerfile.dev → optimized for local development (with hot reload support).
- Dockerfile → optimized for production (builds and serves the static files).
This separation keeps development lightweight while ensuring the production image is small, secure, and ready for deployment.
Dockerfile for development
For development, create a file named Dockerfile.dev. This makes it clear that the file is intended only for development and keeps it separate from your production Dockerfile. A dedicated dev Dockerfile ensures faster builds, hot reloading, and a smoother developer experience without mixing in production concerns.
# =========================================
# Stage: Development (Vite React.js App)
# =========================================
ARG NODE_VERSION=24.14.0-alpine
FROM node:${NODE_VERSION} AS dev
# Set working directory inside the container
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy rest of the source code
COPY . .
# Ensure the "node" user owns the application files
RUN chown -R node:node /app
# Switch to the built-in non-root "node" user
USER node
# Expose Vite dev server port
EXPOSE 5173
# Run Vite in dev mode, accessible outside the container
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
What’s happening here?
FROM node:24.14.0-alpine: We’re using the latest Node.js LTS version of Node.js based on Alpine Linux,
Note: The Node.js image should be monitored regularly, as tags may expire or be rebuilt to include security patches and updates. It’s important to keep the base image tag up to date to ensure your application always benefits from the latest stable and secure release.WORKDIR /app: Sets the working directory inside the container.*COPY package.json ./**: Copiespackage.jsonandpackage-lock.jsonto the working directory.RUN npm install: Installs the dependencies specified inpackage.json.COPY . .: Copies all the files from your local directory into the container.RUN chown -R node:node /app: Ensure the “node” user owns the application files. This prevents permission errors when installing dependencies or writing build artifacts inside the container.USER node: Switches to the built-in non-root “node” user provided by the official Node.js image, improving security by avoiding root privileges inside the container.EXPOSE 5173: Exposes port 5173 ,which is the default port used by the Vite React.js development server.CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]:Defines the default command for the container, starting the Vite development server and making it accessible on all network interfaces.
Production Dockerfile with multi-stage build
When Dockerizing a React app, we need a way to serve the production build. Traditionally, and preferred for production, the developers used Nginx inside Docker to serve the compiled static files. For example, you can follow the official Docker sample guide created by Kristiyan Velkov: Containerize a React application with Docker
Instead in this example , we’ll use the serve package, which is a lightweight static file server built with Node.js.
For a production-ready image, we’ll use a multi-stage build to optimize the image size and enhance security.
# =========================================
# Stage 1: Build the React (Vite) Application
# =========================================
ARG NODE_VERSION=24.14.0-alpine
# 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 package.json package-lock.json ./
# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci
# Copy the rest of the application source code into the container
COPY . .
# Build the React.js application (outputs to /app/dist)
RUN npm run build
# =========================================
# Stage 2: Serve static files with Node.js + `serve`
# =========================================
FROM node:${NODE_VERSION} AS runner
# Set the environment to production for smaller + optimized installs
ENV NODE_ENV=production
# Set the working directory inside the container
WORKDIR /app
# Copy only the production build output from the builder stage
COPY --link --from=builder /app/dist ./dist
# Install only the `serve` package (no global install, pinned version)
RUN --mount=type=cache,target=/root/.npm npm install serve@^14.2.6 --omit=dev
# Run the container as a non-root user for security best practices
USER node
# Expose port 3000 (the same port configured in "serve -l 3000")
EXPOSE 3000
# Run `serve` directly to serve the built app
CMD ["npx", "serve", "-s", "dist", "-l", "3000"]
# Build Stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production Stage
FROM nginx:stable-alpine AS production
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Explanation
Build stage (Stage 1 – builder):
FROM node:${NODE_VERSION} AS builder:Uses a lightweight Node.js 24 Alpine image to build the React (Vite) app.COPY package.json package-lock.json ./:Copies dependency manifests first to optimize Docker layer caching.RUN npm ci:Installs dependencies in a clean and reproducible way based on the lockfile.COPY . .:Copies the full application source code into the container.RUN npm run build: Compiles the app into optimized static files inside the dist/ directory.
Production stage (Stage 2 – runner):
FROM node:${NODE_VERSION} AS runner: Starts a fresh Node.js 24 Alpine image for running the app.ENV NODE_ENV=production: Ensures Node.js runs in production mode.WORKDIR /app: Sets the working directory.COPY --from=builder /app/dist ./dist:Copies only the built production files from the builder stage.RUN npm install serve@^14.2.6 --omit=dev: Installs only theservepackage (pinned version) to serve the static build — no global install, no dev dependencies.USER node: Drops root privileges for security best practices.EXPOSE 3000: Exposes port 3000, the port used byserve.CMD ["npx", "serve", "-s", "dist", "-l", "3000"]: Starts theserveprocess to host the static build.
Benefits
- Smaller image size: The final image contains only the production build and Nginx.
- Enhanced security: Excludes development dependencies and Node.js runtime from the production image.
- Performance optimization:
Serveefficiently serves static files.
Step 3: Create a .dockerignore file
Just like .gitignore helps Git ignore certain files, .dockerignore tells Docker which files or directories to exclude when building the image. Create a .dockerignore file in your project’s root directory:
# =========================================
# Dependencies and build output
# =========================================
node_modules/
dist/
out/
.tmp/
.cache/
# =========================================
# Vite, Webpack, and React-specific artifacts
# =========================================
.vite/
.vitepress/
.eslintcache
.npm/
coverage/
jest/
cypress/
cypress/screenshots/
cypress/videos/
reports/
# =========================================
# Environment and config files (sensitive)
# =========================================
*.env*
!.env.production # Allow production env file if needed
*.log
# =========================================
# TypeScript build artifacts
# =========================================
*.tsbuildinfo
# =========================================
# Debug and lockfiles
# =========================================
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# =========================================
# Local development files
# =========================================
.git/
.gitignore
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# =========================================
# Docker-related files
# =========================================
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.override.yml
# =========================================
# AI / LLM-related files
# =========================================
.openai/
anthropic/
.azureai/
.vercel_ai/
*.ai.json
*.ai.yaml
*.ai.yml
*.llm.json
*.llm.yaml
*.llm.yml
chat_history/
ai_logs/
ai_cache/
Excluding unnecessary files reduces the image size and speeds up the build process.
To learn more, visit the .dockerignore reference.
Step 4: Build and run your dockerized React app
Build the production image:
From the root of your project, run:
docker build -t my-react-app -f Dockerfile .
This command builds the production image using your multi-stage Dockerfile and tags it as my-react-app. By default, it uses the final runner stage, resulting in a smaller, optimized image that contains only the compiled React app and the lightweight serve package.
Run the container:
docker run -p 3000:3000 my-react-app
Now your app is available at http://localhost:3000.
Build the development image:
If you want to run the Vite development server inside Docker, use your dedicated dev Dockerfile:
docker build -t my-react-app-dev -f Dockerfile.dev .
Run the container:
docker run -p 5173:5173 my-react-app-dev
This image includes all build tools and runs the Vite dev server, giving you hot reload at http://localhost:5173.
Note:
Building with Dockerfile.dev (development) produces a larger image since it contains the full toolchain needed for compiling and hot reloading.
Building with Dockerfile (production) produces a smaller, deployment-ready image that only serves the final build output.
Running the Docker container
For the development image:
docker run -p 5173:5173 my-react-app-dev
For the production image:
docker run -p 30000:30000 my-react-app
Accessing your application
Next, open your browser and go to:
http://localhost:5173(for development)http://localhost:3000(for production)
You should see your React app running inside a Docker container.
Step 5: Use Docker Compose for multi-container setups
The following docker-compose.yml acts as a reference configuration for running your React frontend as a service with Docker. It defines two services:
dev→ runs your app in development mode using Vite.prod→ runs your app in production mode using the optimized build served with serve.
Create a file named compose.yml in your project root:
Learn more: Docker Compose.
Create a compose.yml file:
services:
prod:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
dev:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "5173:5173"
environment:
- NODE_ENV=development
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: ./package.json
target: /app/package.json
- action: rebuild
path: ./vite.config.ts
target: /app/vite.config.ts
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- ./node_modules:/app/node_modules
environment:
NODE_ENV: development
stdin_open: true
tty: true
command: npm start
Explanation
- services: Defines a list of services (containers) that Docker Compose will manage.
- dev: The development service, built from
Dockerfile.dev. It runs the Vite development server inside the container. - build: Specifies the build context (. = current directory) and which Dockerfile to use (
Dockerfile.dev).
ports: Maps port 5173 on the container to 5173 on the host, making the Vite dev server accessible at http://localhost:5173. - environment: Sets environment variables, here
NODE_ENV=development. - watch: Monitors local files; syncs source changes instantly and rebuilds the container when key config or dependency files change.
- prod: The production service, built from
Dockerfile. It runs the optimized React build served withserve. - build: Uses the same context (.) but points to the production Dockerfile (
Dockerfile). - ports: Maps port 3000 on the container to 3000 on the host, making the production build accessible at http://localhost:3000.
environment: Sets environment variables, here NODE_ENV=production.
Using Docker Compose
Start all services (foreground mode):docker compose upStart all services in detached mode (background):docker compose up -dStart only the dev service:docker compose up dev --watchStart only the prod service:docker compose up prodStop services:docker compose down
Step 6: Publish your image to Docker Hub
Sharing your Docker image allows others to run your app without setting up the environment themselves.
Log in to Docker Hub:
docker login
Enter your Docker Hub username and password when prompted.
Tag your image:
docker tag my-react-app your-dockerhub-username/my-react-app
Replace your-dockerhub-username with your actual Docker Hub username.
Push the image:
docker push your-dockerhub-username/my-react-app
Your image is now available on Docker Hub for others to pull and run.
Pull and run the image:
docker pull your-dockerhub-username/my-react-app
docker run -p 30000:3000
your-dockerhub-username/my-react-app
Anyone can now run your app by pulling the image.
Handling environment variables securely
Managing environment variables securely is crucial to protect sensitive information like API keys and database credentials.
Using .env files
Create a .env file in your project root:
REACT_APP_API_URL=https://api.example.com
Example compose.yml:
services:
prod:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file:
- .env
environment:
- NODE_ENV=production
Security note: Ensure your .env file is added to .gitignore and .dockerignore to prevent it from being committed to version control or included in your Docker image.
To start all services defined in a compose.yml in detached mode, the command is:
docker compose up -d
Passing environment variables at runtime
Alternatively, you can pass variables when running the container:
docker run -p 5173:5173 -e REACT_APP_API_URL=https://api.example.com my-react-app-dev
Using Docker Secrets (advanced)
For sensitive data in a production environment, consider using Docker Secrets to manage confidential information securely.
Troubleshooting common issues with Docker and React
Even with the best instructions, issues can arise. Here are common problems and how to fix them.
Issue: “Port 3000 is already in use”
Solution: Either stop the service using port 3000 or map your app to a different port when running the container.
docker run -p 4000:3000 my-react-app
Access your app at http://localhost:4000.
Issue: Changes aren’t reflected during development
Solution 1: Use Docker Compose with the --watch flag.
Instead of mounting volumes manually, you can use the new develop.watch feature in Docker Compose (v2.22+). This tells Compose to automatically sync changes from your host into the container and rebuild when needed.
Example in compose.yml:
services:
dev:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "5173:5173"
environment:
- NODE_ENV=development
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: ./package.json
target: /app/package.json
- action: rebuild
path: ./vite.config.ts
target: /app/vite.config.ts
docker compose up dev --watch
Solution 2: Use Docker volumes to enable hot-reloading. In your compose.yml, ensure you have the following under volumes:
volumes:
- .:/app
- ./node_modules:/app/node_modules
These two approaches allow your local changes to be mirrored inside the container.
Issue: Slow build times
Solution: Optimize your Dockerfile to leverage caching. Copy only package.json and package-lock.json before running npm install. This way, Docker caches the layer unless these files change.
COPY package*.json ./
RUN npm install
COPY . .
Issue: Container exits immediately
Cause: The React development server may not keep the container running by default.
Solution: Ensure you’re running the container interactively:
docker run -it -p 3000:3000 my-react-app
Issue: File permission errors
Solution: Adjust file permissions or specify a user in the Dockerfile using the USER directive.
# Add before CMD
USER node
Optimizing your React Docker setup
Let’s enhance our setup with some advanced techniques.
Reducing image size
Every megabyte counts, especially when deploying to cloud environments.
- Use smaller base images: Alpine-based images are significantly smaller.
- Clean up after installing dependencies:
RUN npm install && npm cache clean --force
- Avoid copying unnecessary files: Use
.dockerignoreeffectively.
Leveraging Docker build cache
Ensure that you’re not invalidating the cache unnecessarily. Only copy files that are required for each build step.
Using Docker layers wisely
Each command in your Dockerfile creates a new layer. Combine commands where appropriate to reduce the number of layers for example:
RUN npm install && npm cache clean --force
Conclusion
Dockerizing your React app is a game-changer. It brings consistency, efficiency, and scalability to your development workflow. By containerizing your application, you eliminate environment discrepancies, streamline deployments, and make collaboration a breeze.
So, the next time you’re setting up a React project, give Docker a shot. It will make your life as a developer significantly easier. Welcome to the world of containerization!
Learn more
- Official Docker React.js sample guide.
- Subscribe to the Docker Newsletter.
- Get the latest release of Docker Desktop.
- Have questions? The Docker community is here to help.
- New to Docker? Get started.