How to Deploy a React App: 3 Methods, Step by Step [Full Repo]

Deploy your React app to a public HTTPS URL using GitHub, file upload, or Docker. Tested steps, the deployment errors we actually hit with their one-line fixes, and how to update the app after it is live.

A React app's path from git push through a two-stage Docker build — node builds, nginx serves — to a live HTTPS URL, with the port setting called out as the step the first deploy tripped on

To deploy a React app built with Vite, create a production build and publish the generated dist/ folder to a hosting service, configure a fallback to index.html for client-side routes, and test the app on its public HTTPS URL. This guide shows three ways to do exactly that — and we executed all three on November 9, 2026.

The scope: a client-rendered React SPA built with Vite. Server-rendered React is a different shape — that flow is the Next.js SSR container guide. Everything below, including every error, comes from our own deploys; the finished code is in the companion repo.

Already have a React app? Start here

Identify two things: your build command and its output folder. Then jump to the path that fits.

Your projectBuild outputYour path
Vite (this guide’s example)dist/any of the three below
Create React App (legacy)build/same three paths — swap the folder name
Framework app using server renderinga server processthe SSR container guide instead
Framework app using static exporta folder (e.g. out/)the three paths below, with that folder

Starting fresh instead? npm create vite@latest my-app -- --template react, then cd my-app and npm install — and commit the package-lock.json it generates; you will need it on the Docker path.

One free-plan fact to know before you pick any path: the public URL lives 60 minutes from the app’s creation, and a redeploy does not extend it — plenty to test everything here, not enough to host. Paid plans make it permanent.

Path 1 — GitHub + buildpack: no Dockerfile at all

The fastest path we tested, and the only one that hit zero errors.

  1. Push the app to a GitHub repository and connect it as a New Web Deployment.

The Choose a source step of a new Web Deployment, showing the three paths this guide tests: GitHub repository, Upload files (drag a folder), and Docker Hub image, with the connected repository list below

The three sources on the New Web Deployment screen — dashboard as of November 2026.

  1. In Build & run, pick Buildpacks. The platform detected our Vite app on sight: “This looks like a static site — we’ll serve the built files for you.”

Build Method set to Buildpacks in the app settings: "Back4app detects your stack and builds it" — no Dockerfile in the repository is used on this path

The same repo, built without its Dockerfile. Dashboard as of November 2026.

  1. Create the app. Ours built and came up with no further configuration; the deep-route fallback worked out of the box (a request to an unknown path returned 200 with the app).

The "Your app is ready" dialog after a first deploy: the temporary HTTPS URL with a copy button and a 60-minute expiry countdown, with the option to make it permanent on a paid plan

What “live” looks like on the free plan: a real HTTPS URL and its countdown. Dashboard as of November 2026.

One measured note for later: on this path the bundle arrived uncompressed (details in the measurements section). For testing and small tools that rarely matters; before production traffic it is worth a look.

Path 2 — Upload: a folder becomes a site, no git

  1. Build locally: npm run build — for this path, first add build: { assetsDir: "" } to vite.config.js, so every file sits at the build root.

  2. New Web Deployment → Upload files and hand it the build. The flat build is the procedure we validated end to end — with no folder tree, nothing can get lost in transit. The UI also accepts dragging the dist/ folder itself, designed to keep the tree; we did not exercise that variant with our tooling.

  3. Name the app, create, done — the platform detected a static buildpack and served it.

Why the flat build: in our first automated run the files arrived without their tree, index.html pointed at /assets/…, and the page loaded while the app didn’t. With the flat build, home, deep route and assets all answered 200 on the public URL.

Serving characteristics matched Path 1: fallback present, bundle uncompressed. Updating means re-uploading — which is also this path’s whole appeal: no git, no pipeline.

If you later move a flat build to the Docker path, remove assetsDir: "" (or adapt the nginx cache rule): the long-cache location /assets/ block matches nothing when every file sits at the root.

Path 3 — Docker + nginx: you own the server

Pick this when you want caching, compression and fallback rules in your hands. It is the path where our measured transfer was smallest — and it hit two real errors on the way, both worth knowing (the upload path had its own, covered above).

The Dockerfile

# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Serve stage
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080

Save the two blocks below as Dockerfile and nginx.conf in the repository root. Node builds, nginx serves, nothing from the build stage ships — in this measured example the image is 76.3 MB, nearly all of it nginx:alpine; heavy assets or extra served files move that number.

The nginx config is where the control pays:

server {
    listen 8080;
    root /usr/share/nginx/html;
    index index.html;

    gzip on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/css application/javascript application/json image/svg+xml text/plain;

    # Vite writes content-hashed files to /assets/ — cache them forever
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    # SPA refresh fix: unknown paths fall back to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Deploying it

  1. Connect the repo, keep Dockerfile as the build method.

  2. Set Port to 8080 in Build & deploy. Our first deploy left it on “auto” and failed at launch with Please mention a port in settings to expose — the setting does not adopt the Dockerfile’s EXPOSE.

The Build & deploy settings page: Build Method with Dockerfile selected, the branch and root directory fields, the Autodeploy toggle (a paid-plan feature), and the Port field set to 8080 — the one setting our first deploy failed without

The Port field under Build & deploy — the step our first deploy actually failed on. Dashboard as of November 2026.

  1. Deploy. After the port fix, our redeploy went from trigger to serving the new version in about 25 seconds — verified by a version stamp in the build, not by the dashboard status.

Proving the routes render

A 200 on a deep link proves the fallback; it does not prove the right screen rendered. So we added React Router with two real routes to the companion repo, deployed it, and opened /about directly in a browser:

The About route of the deployed React app, loaded directly by its URL after a refresh: the navigation bar and the route's own heading and text are fully rendered, proving the server fallback hands the URL to React Router

Direct load of /about on the live URL: the route’s actual screen, not a blank page. November 9, 2026.

Environment variables, executed

Set a public value in .env.production:

VITE_API_URL=https://api.example.com/v1

Read it in code like any Vite env value:

const apiUrl = import.meta.env.VITE_API_URL ?? "(not set)"

Rebuild, and the value is in the artifact — we grepped the built bundle and found the literal string inside dist/assets/index-*.js, and the page renders it. Two consequences, both demonstrated rather than asserted:

  • Changing a VITE_ value means rebuilding (and on the GitHub paths, redeploying) — the running container cannot inject it.
  • Any value compiled into the client bundle is readable by whoever downloads it. Secrets never go in VITE_ variables; keys belong behind an API the browser calls. (A secret used only by a build-time process does not automatically enter the bundle — the exposure is about what the client code references.)

Automate the deploy with an AI assistant (MCP)

MCP is not a fourth way to build the app: it is the interface an AI assistant uses to run the deploy for you. The build is still one of the paths above — in our test, the Dockerfile from Path 3.

Connect the Back4app remote server (https://mcp.back4app.com/mcp, OAuth sign-in) to your assistant; the official setup page covers each client. We tested with Claude Code in the Claude desktop app, which reads the folder and runs the upload command; the server was a custom connector, signed in to a Back4app account.

The whole flow, with every tool call and the failures we hit, is in the MCP deploy walkthrough.

Then ask, from the project folder: “Deploy this folder to Back4app through the MCP server, port 8080, and give me the URL when it is ready.” In our run on November 10, 2026 the assistant did three things:

  1. Called create_upload and ran the command it returned, npx @back4app/cli upload, which packed the folder: 30 files, 400 KB, in 11 seconds.

  2. Called create_web_app with port 8080. The app was created with builder: DOCKERFILE — the folder’s Dockerfile was picked up without being asked.

  3. Followed get_deployment until it answered READY, about 43 seconds after creation.

On the public URL, / and the /about route both returned 200, served by the same nginx image as Path 3. In a browser check on a repeat deploy, the counter answered clicks and the About link rendered its route without a reload.

Updating works the same way and keeps the URL. We published a second version to the same app — create_upload with the app’s id, then deploy_web_app — and the new version stamp was being served 30 seconds later, on the same address.

Five things to know before relying on it:

  • .env files stay behind. The upload left .env.production out (“as they may hold secrets”), and the deployed page showed the API endpoint as “(not set)”. Setting VITE_API_URL on the app was not enough: it worked once the Dockerfile’s build stage declared ARG VITE_API_URL and ENV VITE_API_URL=$VITE_API_URL before the build.
  • Name the port. Created without one, this image failed at launch with the same Please mention a port in settings to expose as the dashboard.
  • The buildpack behaved differently here. With builder: BUILDPACK through MCP, this starter built and then failed for lack of a start process; Path 1 in the dashboard had served it as a static site the day before.
  • Settings changes ask first. update_web_app and rollback return a summary and wait for your confirmation; reading logs and deploying new code do not.
  • The Free-plan clock is the same. The URL lives 60 minutes from the app’s creation, and a redeploy does not extend it.

The docs also describe a way to try this without a permanent account; we did not test it.

Tool calls, ids and timings are in the repo’s MCP evidence file and its follow-up.

Updating the app after it is live

The supported flow on the GitHub paths, validated end to end on November 9, 2026: push the commit, then App actions → Deploy the latest commit. Our test push was built and serving on the same public URL shortly after — we confirmed the new file’s content from the live site, not just the dashboard status. On paid plans, auto-deploy on push removes even that click.

Two honest footnotes from the same day of testing:

  • Earlier, on an app whose branch setting had been changed after creation, the same action twice rebuilt a stale commit. If that happens, check the branch in Build & deploy and re-save it.
  • Re-creating the app always reads the branch tip fresh — but it issues a new URL, so treat it as a last resort, not an update flow.

On the upload path, updating is re-uploading the new build.

What we measured

Three kinds of numbers, kept separate — full method and raw output in the repo’s evidence files.

One versioning note: the size and timing rows were measured on the original scaffold, before React Router and the env-var demo entered the repo. The router version was deployed separately to verify rendering; its numbers are not mixed in below.

WhatValueWhere
Public URL, Docker path, before gzip_proxied any;97.4 KB (Content-Encoding: gzip)live URL, November 9, 2026
Public URL, Docker path, after gzip_proxied any; (current starter bundle)115.9 KB → 81.9 KB, nginx’s level-6 outputlive URL, November 10, 2026
Public URL, buildpack and upload paths: same bundle222 KB, no Content-Encodinglive URLs, same date
Local container, Docker path: same bundle at gzip level 668.6 KBlocal measurement
Docker image76.3 MB (arm64; nearly all nginx:alpine)local
Platform build, Docker pathimage built and pushed in ~12 s; redeploy → serving ~25 sdeploy log + version stamp
TTFB on / (3 illustrative requests each)Docker 0.99–1.12 s · buildpack 0.97–1.00 s · upload 0.92–1.05 slive URLs

Why did the public Docker URL transfer 97.4 KB when our nginx compresses that bundle to 68.6 KB? Requests reach the container through a CDN and carry a Via header, and nginx does not compress proxied requests unless told to: gzip_proxied is off by default. Our nginx was answering uncompressed, and the CDN compressed the response its own way.

On November 10, 2026 we added gzip_proxied any; and redeployed. The public transfer of the starter’s current bundle fell from 115.9 KB to 81.9 KB — what nginx produces for that file at level 6. The config above includes the line.

Against the no-Docker paths the direction of the result is unchanged: they served the bundle uncompressed (222 KB), while the Docker path delivers the compression you configure — once nginx is told to apply it to proxied requests.

Common errors

Please mention a port in settings to expose — Docker path: the Port setting is on “auto” and did not read your Dockerfile’s EXPOSE. Set it explicitly in Build & deploy and redeploy.

The npm ci command can only install with an existing package-lock.json — the Vite scaffold created no lockfile. Run npm install locally once, commit package-lock.json, push.

404 when refreshing a route — on the Docker path, the try_files fallback is missing or your nginx.conf is not the file the image copies. On the managed paths the fallback shipped by default in our tests.

Page loads but the app doesn’t (upload path) — the folder tree got lost in the upload and /assets/… is 404. Upload the folder itself, or flatten the build with assetsDir: "".

When is this the wrong answer?

  • Server-side rendering — per-request HTML is a different artifact; use the Next.js SSR container guide.
  • What you actually need is auth, data and APIs — those can live in a managed backend (the React app stays a static container) or in a server of your own; both are legitimate next steps rather than part of this deploy.
  • Still choosing the framework — the Top 10 Vue Alternatives guide maps the field this repo is one entry of.

What’s next?

Publish yours: pick the path that matches where your app lives today — repo, folder or Dockerfile — and take it to an HTTPS URL on the free plan (Containers docs, or start from the companion repo).

When the app needs login, shared data or file storage, that is the optional next step, not a prerequisite: a managed backend serves auth, data and APIs over REST and GraphQL to the app you just deployed.

Tagsreactdockervitenginxcontainersdeployspabuildpack

Frequently asked questions

How do I deploy an existing React app?

Find your build command (usually npm run build) and the folder it produces — dist/ for Vite, build/ for Create React App. Then pick a path: connect the GitHub repo and let the platform's buildpack build it, hand the build to the Upload source (we validated the flat-build variant end to end), or add a Dockerfile for full server control. All three end at a managed HTTPS URL; this guide walks each one as executed on November 9, 2026.

Can I deploy a React app for free?

Yes — every deploy in this guide ran on Back4app's free plan (0.25 vCPU, 256 MB). The free constraint to know before you choose: the public URL lives 60 minutes from the app's creation, which is enough to test the whole flow but not to host. Paid plans make the URL permanent and enable auto-deploy on push.

Do I need Docker to deploy React?

No. In our November 2026 tests, connecting the GitHub repo with the Buildpacks option built and served the app with no Dockerfile in the repository, and uploading the validated flat build needed no git at all. Docker is the path you choose when you want to own the web server's behavior — in our measurements that control was worth a JavaScript transfer less than half the size.

How do I update my app after deployment?

On the GitHub paths: push the commit, then use App actions → Deploy the latest commit — in our November 9, 2026 test the new push was built and serving on the same public URL, confirmed from the live content. Paid plans can turn on auto-deploy so every push deploys itself. On the upload path, updating means re-uploading the build. Re-creating the app also works but issues a new URL, so keep it as a last resort.

Why does refreshing a React route return 404?

Because the server looks for a file named after the route and only index.html exists. The fix is a fallback: in nginx, try_files $uri $uri/ /index.html; the platform's buildpack static server ships an equivalent fallback by default — in our live tests a deep route returned 200 on all three paths, and on the Docker path we verified the rendered screen, not just the status code.

Can I change frontend environment variables without rebuilding?

Not in the standard flow this guide demonstrates: VITE_-prefixed values are read at build time and compiled into the bundle — we set VITE_API_URL in .env.production and found the literal value inside the built JavaScript. Changing it means rebuilding. Runtime configuration patterns exist (for example a config file the page fetches), but they are a different architecture, and no secret belongs in any frontend variable either way.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Nov 9, 2026 — Local: Node 22 LTS, Docker CLI (Colima) on an Apple Silicon Mac · Live: Back4app Containers, free plan (0.25 vCPU / 256 MB), deployed from GitHub, measured November 9, 2026.

Changelog

  • Nov 10, 2026 — Added a section on running this deploy from an AI assistant through the Back4app MCP server, tested on this starter.
  • Nov 10, 2026 — The gap between origin and public transfer sizes is now explained and fixed: nginx needed gzip_proxied any to compress requests relayed by the CDN. Config and figures updated.
  • Nov 10, 2026 — Free-plan URL lifetime restated: 60 minutes from the app's creation. MCP section extended with a browser check, the build-time value route, the port and the buildpack result on this starter.

More from Back4app Engineering

Web Deployment · alternative

Top 10 Vue Alternatives in 2026 (Compared & Ranked)

Containers · tutorial

How to Host a Static Website on Back4app Containers With Docker and nginx [Full Repo]

Web Deployment · tutorial

App Works in Preview but Not in Production: 404 on Refresh, No Data, Login Sent to localhost