Shipping a React app should not end with "it works on my machine". In this guide we take a fresh React project, package it into a small Docker image served by nginx, and hand it to Google Cloud Run so that every push to GitHub becomes a new deployment. This is exactly the setup that runs the site you are reading right now.
By the end you will have:
- A React app created with Vite and pushed to a GitHub repository.
- A development container with hot reload, driven by Docker Compose and a Makefile.
- A production image built in two stages: Node builds the assets, nginx serves them.
- A Cloud Run service that rebuilds and redeploys itself from the repository.
Prerequisites
- Node.js 22 or newer and npm.
- Docker Desktop, or Docker Engine plus the Compose plugin.
- A GitHub account.
- A Google Cloud account with billing enabled. Cloud Run has a generous free tier, and a small static site stays well inside it.
1. Create the React app
Vite is the fastest way to start a React project today, and its build output is plain static files, which is what makes the nginx stage later so simple.
npm create vite@latest react-docker -- --template react-ts
cd react-docker
npm install
npm run devOpen http://localhost:5173 and confirm the starter page renders. One small change makes life easier inside containers: bind the dev server to all interfaces and pin the port.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
server: {
host: true,
port: 8080,
watch: {
usePolling: true, // file events don't cross the Docker volume boundary reliably
},
},
plugins: [react()],
})2. Push it to GitHub
Create an empty repository on GitHub, then connect and push. Cloud Build will later read from this repository, so the branch you push to is the branch that gets deployed.
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin git@github.com:your-user/react-docker.git
git push -u origin main3. Dockerize the project
We need two flavours of container. A development one that mounts your source code and hot reloads, and a production one that contains only the compiled assets and a web server. Four files cover both.
.dockerignore
Keep node_modules and build output out of the Docker build context. Without this, every build copies hundreds of megabytes into the daemon before doing anything useful.
node_modules
dist
.gitDockerfile (development)
FROM node:22-alpine
WORKDIR /app
# Install dependencies first so this layer is cached between source changes
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]docker-compose.yml
The compose file mounts the project into the container so edits show up instantly. The second, anonymous volume on /app/node_modules is the important trick: it stops your (possibly empty) host folder from shadowing the dependencies installed in the image.
services:
web:
container_name: react-docker
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:8080"
volumes:
- .:/app
- /app/node_modules
environment:
- CHOKIDAR_USEPOLLING=true
- NODE_ENV=development
stdin_open: true
tty: trueRun docker compose up -d and open http://localhost:3000. Edit a component and the browser updates without a restart.
Makefile
Not required, but a handful of shortcuts keep the commands you type every day short and consistent across the team.
.PHONY: build up down restart logs login
build:
docker compose build
up:
docker compose up -d
down:
docker compose down
restart: down up
logs:
docker compose logs --tail=100 -f
login:
docker exec -it react-docker shDockerfile.prod (production)
This is a multi-stage build. The first stage has Node and every dev dependency, and its only job is to produce the dist folder. The second stage starts from a tiny nginx image and copies just that folder in. Node, npm and node_modules never make it into the final image, which ends up around 25 MB.
# Stage 0 - build the static assets
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 1 - serve them with nginx
FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=build /app/dist .
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
ENTRYPOINT ["nginx", "-g", "daemon off;"]nginx.conf
Two details matter here. Cloud Run sends traffic to the port in the PORT environment variable, which defaults to 8080, so nginx listens there. And a single-page app needs every unknown path to fall back to index.html, otherwise refreshing /articles/hello returns a 404 from nginx before React ever loads.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hashed build assets can be cached for a long time
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Client-side routing: unknown paths fall back to the SPA entry point
location / {
try_files $uri $uri/ /index.html;
}
}Build and run the production image locally before involving the cloud:
docker build -f Dockerfile.prod -t react-docker:prod .
docker run --rm -p 8080:8080 react-docker:prodIf http://localhost:8080 shows your app, and a deep link such as http://localhost:8080/anything still loads it, the image is ready.
4. Deploy to Google Cloud Run
Cloud Run runs containers and scales them to zero when nobody is visiting, so a personal site costs nothing while idle. Better still, it can watch your repository and rebuild on every push, which turns Git into your deployment tool.
Create a project
- Open the Google Cloud Console and create a new project. Note the project ID, you will see it in image names later.
- Enable the Cloud Run, Cloud Build and Artifact Registry APIs. The console prompts you when you first open each product.
Create the service with continuous deployment
- Go to Cloud Run and click Create service.
- Choose Continuously deploy new revisions from a source repository and click Set up with Cloud Build.
- Connect your GitHub account, pick the repository you pushed in step 2 and the branch to deploy, for example
^main$. - For the build type select Dockerfile and set the source location to
/Dockerfile.prod. Save. - Give the service a name and a region close to your users. Under Authentication choose Allow unauthenticated invocations, since this is a public website.
- Expand Container, Networking, Security and confirm the container port is 8080, matching
nginx.conf. - Click Create.
Cloud Build clones the repository, builds Dockerfile.prod, pushes the image to Artifact Registry and deploys it. The first build takes a couple of minutes. When it finishes the service page shows a *.run.app URL, and your app is live.
Optional: describe the pipeline in cloudbuild.yaml
The console wizard creates the build trigger for you. If you prefer the pipeline to live in the repository, where it is reviewed and versioned like everything else, add a cloudbuild.yaml and change the trigger's build type to Cloud Build configuration file.
steps:
# 1. Build the production image
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/react-docker:$COMMIT_SHA', '-f', 'Dockerfile.prod', '.']
# 2. Push it to the registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/react-docker:$COMMIT_SHA']
# 3. Deploy the new revision to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'react-docker'
- '--image=gcr.io/$PROJECT_ID/react-docker:$COMMIT_SHA'
- '--region=europe-west1'
- '--platform=managed'
- '--allow-unauthenticated'
options:
logging: CLOUD_LOGGING_ONLY
images:
- 'gcr.io/$PROJECT_ID/react-docker:$COMMIT_SHA'Tagging with $COMMIT_SHA instead of latest means every revision in Cloud Run points at an immutable image, so rolling back is a single click in the console.
5. Verify the loop
Change some text in src/App.tsx, commit and push. Watch the build appear under Cloud Build → History, then reload the run.app URL. That round trip, from git push to a live change with no manual step in between, is the whole point of the setup.
Where to go next
- Custom domain: map your own domain from the Cloud Run service page. Certificates are provisioned and renewed automatically.
- Preview environments: add a second trigger on pull request branches that deploys to a service with
--no-trafficand a tagged URL. - Smaller images: switch
npm installtonpm cieverywhere, and commit yourpackage-lock.jsonso builds are reproducible.