Dockerize Your MERN App
Package your MERN stack into production-ready containers with Docker Compose.
Introduction
30 Second Summary
You've built an app that works on your laptop. But the moment a teammate clones it, they hit different Node versions, missing dependencies, and broken configs. The gap between "works on my machine" and "works anywhere" is the gap Docker closes.
In this project, you will containerize a full MERN stack (React frontend, Express backend, and MongoDB Atlas database) using Docker and wire them together with Docker Compose.
What You'll Build
By the end, you'll run a single docker compose up command and watch your React frontend, Express API, and database connection all come alive in separate containers that talk to each other over a shared network.
By the end of this project, you'll have:
- A production-ready Dockerfile for your Express backend using multi-stage builds, tested with docker build and docker run.
- A production-ready Dockerfile for your React frontend, serving static files through nginx — built and run locally to verify it works.
- A Docker Compose configuration that spins up both containers with networking, environment variables, and a connection to your cloud database in one command.
- Secret Mission: Harden your containers for production with health checks, restart policies, and optimized image layers.
Are there any prerequisites?
You'll need an existing MERN app (React frontend + Express/Node backend) with a GitHub repository. This project will guide you through installing Docker Desktop and setting up MongoDB Atlas.
Before we dive in, let's lock in what you're about to build and why it matters. You're going to take a working MERN app and package it into containers that run identically on any machine.
Before we dive in, let's lock in what you're about to build and why it matters. You're going to take an app that only runs on your machine and package it so it runs identically anywhere.
Dockerize Your Backend
Get Ready
To containerize your app, you need Docker running on your machine. Docker is the engine that builds and runs containers — without it, none of the Dockerfiles you write will do anything.
In this step, you'll install Docker Desktop, verify it works, and make sure your project is ready to containerize.
In this step, get ready to:
- Install and verify Docker Desktop.
- Open your MERN project in your code editor.
Install Docker Desktop
- Download Docker Desktop from https://www.docker.com/products/docker-desktop/ and run the installer for your operating system.
- Follow the installation prompts and restart your computer if prompted.
- Open Docker Desktop. Wait until the whale icon in your system tray (Windows) or menu bar (macOS) shows a steady green state.
What is Docker Desktop?
Docker Desktop bundles the Docker engine, CLI tools, and a GUI for managing containers. It handles all the Linux virtualisation under the hood so you can run containers on macOS and Windows without configuring anything yourself.
- Verify Docker is installed by running this command:
docker --version
✔️ I see a version number
You're all set. You should see something like Docker version 27.x.x. Move on to the next substep.
ⓧ Command not found
Make sure Docker Desktop finished installing and is running. Check for the whale icon in your system tray (Windows) or menu bar (macOS). If you just installed it, try opening a new terminal window.
help me troubleshoot my Docker installation
Open your MERN project
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open your MERN project folder (the one containing both your frontend and backend directories).
What project structure do I need?
This project assumes your repository has a backend folder (containing package.json and server.js or similar entry point) and a frontend folder (a React app with its own package.json).
If your project is structured differently (monorepo, single folder, etc.), the Dockerfiles in the next steps will still work — you'll just adjust the file paths.
- Confirm you can see your project files in the VS Code Explorer sidebar. You should see your backend and frontend folders listed.
Your tools are ready. Next up, you'll set up a free MongoDB Atlas cluster so your containerized backend has a cloud database to connect to.
Set Up MongoDB Atlas
Your backend is containerized and running locally, but it still needs a database. When your app runs inside a Docker container, a hardcoded localhost connection string won't work — the container has its own isolated network.
You need a cloud-hosted database that any container can reach over the internet. MongoDB Atlas provides a free managed cluster that handles replication and scaling for you.
In this step, get ready to:
- Deploy a free MongoDB Atlas cluster.
- Configure network access so your Docker containers can connect.
- Get the connection string your backend will use.
Create a MongoDB Atlas cluster
Atlas offers a free M0 cluster with 512 MB of storage. This is more than enough for a learning project.
- Open https://cloud.mongodb.com in your browser and sign in (or create a free account if you don't have one).
- Once you're on the project overview page, click Create.
- Select the M0 (Free) tier.
- Under cloud provider, select AWS.
- Under region, select us-east-1 (N. Virginia).
- Give your cluster a name (the default is fine).
- Click Create Deployment.
Atlas will prompt you to create a database user.
- Enter a Username (e.g. admin).
- Enter a Password or click Autogenerate Secure Password.
- Save this username and password somewhere safe. You will need them for the connection string.
- Click Create Database User.
Don't see the M0 option?
Atlas allows one free cluster per project. If you already have a free cluster in this project, either delete the existing one or create a new Atlas project first.
help me find the free tier option in MongoDB Atlas
Configure network access and get the connection string
By default, Atlas blocks all incoming connections. You need to whitelist the IP addresses that can reach your cluster.
- In the Atlas left sidebar, click Network Access (under the Security section).
- Click Add IP Address.
- In the IP address field, enter 0.0.0.0/0.
- Click Confirm.
Why allow access from anywhere?
Your Docker containers run on your local machine, but their outbound IP can change depending on your network. Using 0.0.0.0/0 removes the need to update the access list every time your IP changes.
Atlas still enforces username and password authentication, so your database is not open to anonymous connections. In production, you would restrict this to specific IPs or use VPC peering.
Now grab the connection string your backend will use.
- In the left sidebar, click Database to return to your clusters.
- Click Connect on your cluster.
- Click Drivers.
- Select Node.js from the driver dropdown.
- Copy the connection string shown. It looks like this:
mongodb+srv://<username>:<db_password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority
- Replace <db_password> with the password you created for your database user.
- Save this complete connection string. You will use it as your MONGODB_URI value in your Docker Compose configuration later.
Keep your connection string handy
Paste the connection string (with your real password substituted in) into a text file or note for now. You will add it to a .env file when you set up Docker Compose.
Your database is live in the cloud. Next up, you'll write a production Dockerfile for your Express backend and test it locally with docker build and docker run.
Dockerize Your Backend
Your database is live and your connection string is ready. Now you need to package your Express backend so it runs inside a container instead of relying on your local Node.js install.
A Dockerfile is a recipe that tells Docker exactly how to build an image of your application. You'll write one using a multi-stage build pattern that keeps the final image small by separating dependency installation from the runtime.
In this step, get ready to:
- Write a production Dockerfile for your Express backend.
- Build the image and run it locally to verify it works.
- Create a .dockerignore file to keep the image clean.
Create a Dockerfile for your backend
- In your backend folder (the one containing package.json), create a new file called Dockerfile (no file extension).
- Paste the following code into Dockerfile:
# Stage 1: Install production dependencies only
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Copy dependencies and app code into the final image
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 5000
ENV NODE_ENV=production
CMD ["node", "server.js"]
What does this Dockerfile do?
- Stage 1 (builder): Starts from a lightweight node:20-alpine image, copies only package.json and package-lock.json, then installs production dependencies.
- Stage 2: Starts a fresh node:20-alpine image, copies the node_modules from Stage 1, then copies your app source code.
- EXPOSE 5000 documents that the container listens on port 5000.
- CMD ["node", "server.js"] runs your backend when the container starts.
The multi-stage approach means your final image only contains what is needed to run, keeping it as small as possible.
- Save Dockerfile.
Create a .dockerignore file
A .dockerignore file tells Docker which files to skip when copying your project into the image. Without it, Docker copies everything — including node_modules, .env files, and git history.
- In the same backend folder, create a file called .dockerignore and paste the following:
node_modules
npm-debug.log
.env
.git
.gitignore
Dockerfile
.dockerignore
Why exclude node_modules?
Your Dockerfile runs npm ci inside the container to install fresh dependencies. Copying your local node_modules would overwrite those with platform-specific binaries that may not work inside the Alpine Linux container.
- Save .dockerignore.
Build and test the Docker image locally
Now that your Dockerfile is ready, build an image from it and run a container to confirm your API starts up.
- Build the Docker image by running this command from your backend folder:
docker build -t mern-backend .
What does this command do?
docker build reads your Dockerfile and executes each instruction to produce an image. The -t mern-backend flag names the resulting image so you can reference it easily. The . tells Docker to use the current directory as the build context.
You should see Docker processing each stage and finishing with a line that names your image.
Build failing?
If you see a COPY failed error, make sure you are running the command from the same folder that contains your Dockerfile and package.json.
If npm cannot resolve packages, confirm that your package-lock.json is present in the project root. Run npm install once locally to regenerate it if needed.
help me debug my Docker build error
- Run your new image as a container by running:
docker run -d --name backend -p 5000:5000 -e MONGODB_URI="your-atlas-connection-string-here" mern-backend
What does this command do?
- -d runs the container in the background (detached mode).
- --name backend gives the container a name so you can reference it later.
- -p 5000:5000 maps port 5000 on your machine to port 5000 inside the container.
- -e MONGODB_URI=... passes your Atlas connection string as an environment variable.
- Test that your API responds by running:
curl http://localhost:5000
You should see a response from your API. This confirms your backend is running inside a container and can reach MongoDB Atlas.
- Check that the container is running by running:
docker ps
You should see a row with backend listed under the NAMES column and Up in the STATUS column.
Container not showing in docker ps?
If docker ps shows nothing, your container may have exited. Run docker logs backend to see why. Common causes include a missing entry point file (check that server.js matches the CMD in your Dockerfile) or an invalid MongoDB connection string.
help me figure out why my Docker container exited
- Stop and remove the test container by running:
docker stop backend
docker rm backend
This cleans up the local test container so it won't conflict with Docker Compose later.
Your backend is containerized and proven to work locally. Next up, you'll do the same for your React frontend — but with a twist: the frontend gets built into static files and served by nginx.
Dockerize Your Frontend
Your backend is containerized. Now your React frontend needs the same treatment. In development, you run npm start and a dev server handles everything. In production, that dev server is too slow and insecure.
Instead, you'll build your React app into static HTML/CSS/JS files and serve them with nginx — a fast, production-grade web server. The Dockerfile uses a multi-stage build: one stage runs npm run build, the next stage copies those files into a tiny nginx image.
In this step, get ready to:
- Write a multi-stage Dockerfile for your React frontend.
- Configure nginx to serve your built app.
- Build and run the frontend container locally.
Create a Dockerfile for your frontend
- In your frontend folder (the one containing your React app's package.json), create a new file called Dockerfile.
- Paste the following code into Dockerfile:
# Stage 1: Build the React app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve the built files with nginx
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
What does this Dockerfile do?
- Stage 1 installs all dependencies (including dev dependencies needed to build), copies your source code, and runs npm run build to produce optimised static files.
- Stage 2 starts from a tiny nginx:alpine image (about 40 MB) and copies only the built files from Stage 1.
- The final image contains no Node.js, no source code, and no node_modules — just nginx and your static files.
This is why multi-stage builds are powerful for frontends. Your build tooling stays in Stage 1 and never ships to production.
💡 Using Vite instead of Create React App?
If your React app uses Vite, the build output folder is dist instead of build. Update the COPY line in Stage 2 to COPY --from=builder /app/dist /usr/share/nginx/html.
- Save Dockerfile.
Create the nginx configuration
React apps use client-side routing. Without a custom nginx config, refreshing any page other than the homepage returns a 404. You need nginx to serve index.html for all routes so React's router can handle them.
- In the same frontend folder, create a file called nginx.conf and paste the following:
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend container
location /api {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
What does this config do?
- try_files $uri $uri/ /index.html sends all unmatched routes to index.html so React Router can handle navigation.
- The /api location block forwards any request starting with /api to the backend container on port 5000.
- proxy_pass http://backend:5000 uses the container name backend as a hostname. Docker Compose's internal DNS resolves this automatically.
- Save nginx.conf.
Create a .dockerignore for the frontend
- In the frontend folder, create a .dockerignore file and paste:
node_modules
build
dist
.env
.git
Dockerfile
.dockerignore
- Save .dockerignore.
Build and test the frontend container
- Build the frontend image by running this command from your frontend folder:
docker build -t mern-frontend .
This takes longer than the backend build because npm run build compiles and bundles your entire React app.
Build failing on npm run build?
If the build fails with a React compilation error, the same error would happen if you ran npm run build locally. Fix any lint or type errors in your React code first, then rebuild the image.
help me debug my frontend Docker build error
- Run the frontend container by running:
docker run -d --name frontend -p 3000:80 mern-frontend
Why map port 3000 to port 80?
Inside the container, nginx listens on port 80 (the standard HTTP port). Mapping -p 3000:80 means you can visit http://localhost:3000 on your machine and it reaches port 80 inside the container.
- Open http://localhost:3000 in your browser.
You should see your React app's UI. API calls will fail at this point because the frontend container can't reach the backend container yet — that's what Docker Compose will solve in the next step.
- Stop and remove the test container by running:
docker stop frontend
docker rm frontend
Both containers work individually. Next up, you'll connect them with Docker Compose so the frontend can talk to the backend and your whole stack runs in a single command.
Compose and Connect
You've proven that each container works on its own. But running docker run with long flags for every container is tedious and error-prone. You also need the containers to find each other by name over a shared network.
Docker Compose solves both problems. You define all your services, ports, environment variables, and networking in a single YAML file. One command brings everything up.
In this step, get ready to:
- Create a Docker Compose file that defines both services.
- Configure networking and environment variables.
- Run the full stack and verify end-to-end communication.
Create the environment file
Your backend needs the MongoDB connection string, but you should never hardcode secrets into a Compose file that gets committed to Git. A .env file keeps secrets separate.
- In the root of your project (the folder containing both your frontend and backend folders), create a file called .env and add the following:
MONGODB_URI=mongodb+srv://admin:your-password-here@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority
- Replace the placeholder with your actual Atlas connection string (the one you saved in Step 2).
- Save .env.
Add .env to your .gitignore
If you haven't already, add .env to your root .gitignore file so your database credentials never get pushed to GitHub.
Create the Docker Compose file
- In the same root folder, create a file called docker-compose.yml and paste the following:
services:
backend:
build: ./backend
container_name: backend
ports:
- "5000:5000"
environment:
- MONGODB_URI=${MONGODB_URI}
- NODE_ENV=production
networks:
- mern-network
frontend:
build: ./frontend
container_name: frontend
ports:
- "3000:80"
depends_on:
- backend
networks:
- mern-network
networks:
mern-network:
driver: bridge
What does this Compose file do?
- services: defines two containers: backend and frontend.
- build: ./backend tells Compose to build the image from the Dockerfile inside the backend folder.
- environment: passes the MONGODB_URI from your .env file into the container at runtime.
- depends_on: ensures the backend container starts before the frontend.
- networks: creates a shared bridge network called mern-network. Both containers join this network, which means the frontend can reach the backend using http://backend:5000 as a URL.
Docker's built-in DNS resolves container names to IP addresses automatically within the same network. That's why your nginx config uses proxy_pass http://backend:5000 — Docker handles the rest.
- Save docker-compose.yml.
Run the full stack
Before you run this, predict what you expect to happen. Two containers should start, join the same network, and the frontend should be able to proxy API requests to the backend.
- Start the full stack by running this command from the project root:
docker compose up --build
What does --build do?
The --build flag forces Docker Compose to rebuild both images from their Dockerfiles before starting. Without it, Compose would reuse cached images from previous builds, which can hide code changes you've made.
You should see logs from both containers in your terminal. The backend logs its MongoDB connection, and the frontend starts nginx.
Seeing a connection refused error from the backend?
If your backend logs show a MongoDB connection error, double-check that your .env file is in the same folder as docker-compose.yml and that the MONGODB_URI value contains your real password (not the <db_password> placeholder).
help me troubleshoot my Docker Compose connection error
- Open a new terminal tab and verify both containers are running:
docker compose ps
You should see both backend and frontend with a status of Up.
- Test the backend directly by running:
curl http://localhost:5000
You should see a JSON response from your Express API.
- Open http://localhost:3000 in your browser.
You should see your React app fully functional — including any features that fetch data from the backend. The frontend proxies API requests through nginx to the backend container over the shared Docker network.
Frontend loads but API calls fail?
Make sure your React app sends API requests to paths starting with /api (matching the nginx proxy location). If your backend routes don't use the /api prefix, update the location block in nginx.conf to match your actual route prefix.
help me fix API routing between my frontend and backend containers
- When you're done testing, stop the stack by pressing Ctrl+C in the terminal where Compose is running, then run:
docker compose down
What does docker compose down do?
This command stops and removes all containers, networks, and volumes created by docker compose up. Your images stay cached, so the next docker compose up will be fast unless you change code.
Secret mission
Harden for Production
Your containers run, but they're not production-hardened yet. What happens if your backend crashes at 3 AM? Right now, nothing restarts it. In this secret mission, you'll add health checks, restart policies, and optimised image layers so your stack recovers from failures automatically.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing compute costs. The MongoDB Atlas M0 cluster is free indefinitely.
Resources you used:
- Docker images built locally (mern-backend, mern-frontend).
- Docker containers and networks created by Docker Compose.
- MongoDB Atlas M0 cluster (free tier database).
- Project files: Dockerfiles, docker-compose.yml, nginx.conf, .env.
Keep everything running
No action needed. Choose this if you're still actively building on your containerised app or want to keep experimenting with Docker Compose.
- Your Docker images remain cached locally for fast rebuilds.
- Run docker compose up any time to spin the stack back up.
- Your Atlas M0 cluster stays free with no action required.
Pause - I'll come back to this later
Stop running containers to free up memory while keeping your images and files intact.
- Stop and remove all running containers by running:
docker compose down
- Your images stay cached. Run docker compose up --build to restart the stack when you return.
- Your Atlas cluster remains active and free in the background.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Remove Docker containers, images, and networks
- Stop and remove all project containers and networks by running:
docker compose down
- Remove the Docker images you built by running:
docker rmi mern-backend mern-frontend
Delete the MongoDB Atlas cluster
- Log in to MongoDB Atlas at https://cloud.mongodb.com/.
- Navigate to your project and find your M0 cluster.
- Click the three-dot menu (...) next to the cluster name and select Terminate.
- Type the cluster name to confirm and click Terminate.
Remove project files
- Delete the Docker-related files from your project by running:
rm -f docker-compose.yml .env
rm -f backend/Dockerfile backend/.dockerignore
rm -f frontend/Dockerfile frontend/.dockerignore frontend/nginx.conf
That's a Wrap!
That's a Wrap!
Nice work! You've just containerised a full MERN stack and orchestrated it with Docker Compose — ready to ship anywhere.
You've learned how to:
- Written production Dockerfiles with multi-stage builds for both a Node.js backend and a React frontend served by nginx.
- Used docker build and docker run to test each container individually before composing them.
- Created a Docker Compose configuration with shared networking, environment variables, and service dependencies that spins up the full stack in one command.
- Secret Mission: Added health checks and restart policies so your containers self-heal from crashes without manual intervention.
Ready to quiz yourself?