To install React dependencies only once in a Docker container, you must leverage multi-stage builds to separate the build environment from the runtime environment. This approach ensures that heavy compilation tools and node modules are not carried over into the final image, significantly reducing size and improving security while maintaining fast startup times for your application.
The Problem with Standard Builds
When developers first attempt to containerize a React application, they often create a single
Dockerfile that installs all dependencies, builds the static assets, and then runs the development server or serves the files. While this works locally, it leads to bloated images because the final container contains the entire Node.js source tree, compiler artifacts, and development dependencies. Furthermore, every time code changes, Docker may re-run the expensive dependency installation step if caching is not managed perfectly. The goal is to isolate the build phase so that only the necessary production-ready files are copied into the final runtime stage.
Setting Up the Multi-Stage Build
A multi-stage build allows you to define multiple
FROM instructions within a single
Dockerfile. Each stage starts fresh, inheriting from a base image but discarding previous layers unless explicitly copied. The first stage will act as the builder, where we install all dependencies and compile the React app. The second stage will be the minimal runtime, containing only the web server needed to serve the compiled output. This separation is critical for achieving the "install once" efficiency you seek.
Start by defining the builder stage using a standard Node.js image. You should specify a version that matches your local development environment to avoid compatibility issues. Inside this stage, set the working directory and copy the package files first. This strategy exploits Docker's layer caching; since
package.json and
package-lock.json change less frequently than source code, Docker can skip the installation step if these files remain unchanged.
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
The command
npm ci is preferred over
npm install in CI/CD and containerized environments because it performs a clean install based strictly on the lock file, ensuring deterministic builds. By specifying
--only=production, you exclude dev dependencies like testing libraries or linters, which are not needed in the final container. After installing dependencies, the full source code is copied, and the build script generates the optimized static files in the
build directory.
Creating the Minimal Runtime Image
The second stage focuses on serving the application efficiently. Instead of using a large Node.js image, you can use an even lighter HTTP server like Nginx or Apache, or a minimal Node.js image configured to serve static files. Using Nginx is a common choice for React apps because it handles static content delivery very efficiently.
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
In this snippet, the
COPY --from=builder instruction copies the compiled output from the first stage directly into the Nginx document root. Notice that no
npm install or source code is present in this stage. The resulting image contains only the Nginx binary, the configuration, and the static HTML/CSS/JS files. This drastically reduces the image size compared to keeping the Node.js runtime and source code.
Building and Running the Container
Once the
Dockerfile is structured this way, building the image is straightforward. Use the standard
docker build command. Docker will automatically execute the stages in order, creating intermediate containers for each stage but only retaining the final result.
docker build -t my-react-app .
After the build completes, you can run the container. The application will be accessible on port 80 (or mapped to a host port) without requiring any further installation steps. If you modify your React source code, Docker’s cache invalidation will trigger the build stage again, but the dependency installation will still be cached unless
package.json changes, ensuring that the most time-consuming part of the process is skipped whenever possible.
Optimizing Cache Invalidation
To maximize the "install once" benefit, ensure that you do not copy unnecessary files before copying
package.json. If you copy the entire project directory before installing dependencies, Docker cannot reuse the cache layer for
npm ci even if the packages haven't changed. Always copy the manifest files first, run the install, and then copy the rest of the source code. This order guarantees that dependency installation is truly isolated and cached effectively.
Frequently Asked Questions
Why does my Docker image still have a large size?
If your image remains large, check if you are accidentally copying the
node_modules folder from the host into the final stage. Ensure you are using
COPY --from=builder to move only the built artifacts and not the source directory. Also, verify that you are using a slim or alpine variant for your base images to reduce overhead.
Can I use yarn instead of npm for faster installs?
Yes, you can replace
npm ci with
yarn install --frozen-lockfile if your project uses Yarn. The principle remains the same: copy the lock file first, install dependencies, and then copy the source code. Yarn often provides faster installation speeds due to its parallel processing capabilities, which can further reduce build times.
What happens if I update a dependency in package.json?
When
package.json changes, Docker detects that the layer has been modified and will invalidate the cache for all subsequent steps. This means
npm ci will run again to fetch the new dependencies. This is expected behavior and ensures your container always reflects the correct dependency tree defined in your manifest.
Is it safe to remove devDependencies in production?
Yes, removing devDependencies is a best practice for production containers. DevDependencies include tools like Jest, ESLint, and TypeScript compilers, which are only needed during development. Excluding them reduces the attack surface and storage footprint, making your container more secure and efficient.
How do I handle environment variables in the built app?
React applications typically inject environment variables at build time using
REACT_APP_ prefixed variables. These values are baked into the static JavaScript files during the
npm run build step. Therefore, you cannot change them at runtime without rebuilding the image. For dynamic configuration, consider using a separate config service or modifying the Nginx configuration to proxy requests.