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.

terminal
npm create vite@latest react-docker -- --template react-ts
cd react-docker
npm install
npm run dev

Open 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.

vite.config.ts
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.

terminal
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 main

3. 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.

.dockerignore
node_modules
dist
.git

Dockerfile (development)

Dockerfile
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.

docker-compose.yml
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: true

Run 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.

Makefile
.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 sh

Dockerfile.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.

Dockerfile.prod
# 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.

nginx.conf
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:

terminal
docker build -f Dockerfile.prod -t react-docker:prod .
docker run --rm -p 8080:8080 react-docker:prod

If 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

  1. Open the Google Cloud Console and create a new project. Note the project ID, you will see it in image names later.
  2. 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

  1. Go to Cloud Run and click Create service.
  2. Choose Continuously deploy new revisions from a source repository and click Set up with Cloud Build.
  3. Connect your GitHub account, pick the repository you pushed in step 2 and the branch to deploy, for example ^main$.
  4. For the build type select Dockerfile and set the source location to /Dockerfile.prod. Save.
  5. Give the service a name and a region close to your users. Under Authentication choose Allow unauthenticated invocations, since this is a public website.
  6. Expand Container, Networking, Security and confirm the container port is 8080, matching nginx.conf.
  7. 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.

cloudbuild.yaml
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-traffic and a tagged URL.
  • Smaller images: switch npm install to npm ci everywhere, and commit your package-lock.json so builds are reproducible.