Deploy a Vite single-page app with nginx

March 29, 2026
Tutorial

A React app built with Vite compiles to a folder of static files. Serving them correctly takes two nginx rules that are easy to miss:

  • every client-side route must fall back to index.html;
  • the hashed files under /assets/ can be cached forever, while index.html must not be.

This tutorial packages a Vite app in a small nginx image and deploys it on JetDeploy with git push.

git pushbranch mainBuildnpm run build → nginx imageProcess webport check on :8080HTTPS edgemy-spa.jetdeploy.app
Every push to the App branch builds the Dockerfile, starts the process and routes HTTPS traffic to it once port 8080 accepts connections.

What you need

  • A Vite project (npm create vite@latest with any template). The examples use React.
  • A JetDeploy account, and the Git Access Token from Integrations › Git Access Token in the Console.
  • Docker locally, if you want to try the image before pushing. It is optional.

1. The nginx configuration

Create nginx.conf at the root of the repository:

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

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri/ /index.html;
    }
}
Request What nginx answers Cache
/ or /pricing/annual index.html, so the router in the browser takes over revalidated on every visit
/assets/index-CyBHeG3D.js the file itself one year, immutable
/assets/missing.js 404, not index.html none

The /assets/ block answers 404 for a missing file on purpose. Falling back to index.html there would hand the browser HTML where it expects JavaScript, and the page would fail with a confusing MIME error instead of a clear 404.

2. The Dockerfile

JetDeploy builds the Dockerfile at the root of the repository. A two-stage build keeps Node.js and node_modules out of the final image:

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginxinc/nginx-unprivileged:1.30-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080

Add a .dockerignore, so the local node_modules and dist never reach the build context:

node_modules
dist
.git

The unprivileged nginx image runs as the nginx user rather than root, which is why it listens on 8080 instead of 80.

Build-time values. The build does not see the environment variables of the App. Anything Vite inlines with import.meta.env.VITE_* has to come from a file in the repository, such as .env.production. Everything in the bundle is public anyway, so never put a secret there.

3. Try it locally (optional)

docker build -t my-spa .
docker run --rm -p 8080:8080 my-spa
curl -sI http://localhost:8080/some/client/route | grep -i cache-control
# Cache-Control: no-cache

With this image, a deep link answers with index.html, the hashed bundle with the one-year header, and a missing asset with 404.

4. Create the App and push

  1. In the Console, create an App, for example my-spa, on the branch main.
  2. Add the remote and push:

bash git remote add jetdeploy https://jetdeploy.com/git/my-spa git push jetdeploy main

Git asks for credentials: the username is git and the password is your Git Access Token. 3. Add a process: name web, container port 8080, external-facing on. No command is needed, since the nginx image already has one. 4. Click Deploy. The first push arrived before the App had a process, so it was recorded but not built.

From then on, every push to main builds and rolls out a new version by itself. The new version receives traffic only once it accepts connections on port 8080.

5. Check the result

The App answers at https://my-spa.jetdeploy.app. HTTPS is terminated at the edge, and plain http:// requests are redirected before they reach nginx.

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://my-spa.jetdeploy.app/
# 301 https://my-spa.jetdeploy.app/
curl -s https://my-spa.jetdeploy.app/settings/profile | grep -o '<div id="root">'
# <div id="root">

Common mistakes

  • nginx and the process disagree on the port. nginx listens on 80 while the container port is 8080, or the other way round. The readiness check waits on a port nobody listens on, and the error of the deploy says that nothing listens on the port of the process.
  • try_files $uri /index.html inside /assets/. A missing chunk returns HTML with status 200, and the page fails with a MIME-type error instead of a clear 404.

Next step

The same flow works for any site that builds to static files: Astro, Hugo, SvelteKit with the static adapter. Only the build stage changes. The Build and Deploy with git push sections of the docs cover the details, including how to keep the token out of your git config.

Tested locally with Vite 8.3, React 19.2 and nginx 1.30 (Docker build and the requests above). The JetDeploy steps follow the documentation.

Related Articles

All posts