Deploy a static site to a VPS: Vite SPA or plain HTML

Deploy a Vite single-page app, an Astro site or plain HTML to your own VPS. Caddy serves the files over HTTPS, no app process, with a tiny ox.toml or none.

Last updated 2026-10-08

On this page

To deploy a static site to a VPS with ox, add the repo as a project and deploy: ox detects a Vite app (React, Vue, Svelte), an Astro site or plain HTML, runs your build, and Caddy serves the files over HTTPS with no app process. A Vite app or plain HTML needs no ox.toml; write a short one for your own domain or when the output folder is not the usual one.

ox reads the ox.toml on this page, makes the plan on the card, and builds the site and serves dist as files on your server.

What ox detects in a static site repo

ox reads the files at the root of your repo:

  • Vite: a package.json with vite as a dependency, a build script and no start script. ox builds it and serves dist, or the build.outDir in vite.config.*, as a single-page app. This needs no ox.toml.
  • Plain HTML: an index.html at the root and no package.json. ox serves the whole repo as it is, with no build. This needs no ox.toml either.
  • Astro: astro as a dependency and an astro.config.* file. ox builds it and serves dist, or the outDir your config names. An Astro config with a server adapter is not a static site, and ox check says so.
  • Jekyll: a Gemfile with jekyll (or github-pages), its Gemfile.lock, a _config.yml, and a pinned Ruby (.ruby-version). ox installs the gems, runs jekyll build in production and serves _site, or the destination your config names. It is the plan only when the repo holds no other stack's app. With the github-pages gem and no repository: in _config.yml, set PAGES_REPO_NWO to owner/repo: a release has no .git to read the name from.
  • Other generators: Docusaurus (build), Eleventy (_site), Hexo (public, with a _config.yml and a source folder), VitePress (.vitepress/dist), SvelteKit with adapter-static (build), Nuxt with a generate script (.output/public) and a Next.js static export (out). Each needs its dependency and its config file; VitePress needs a build script that is just vitepress build (or vitepress build docs, which serves docs/.vitepress/dist). Hexo and VitePress are used only when no web server library is a dependency, so an Express app that also builds docs stays the Express app. A Nuxt app whose build script runs nuxt build is a server, not a static site, unless its config sets ssr: false.

For a build, the install comes from your lockfile (npm ci --include=dev for package-lock.json, pnpm install --frozen-lockfile (with --prod=false before pnpm 10), yarn install --frozen-lockfile --production=false or bun install --frozen-lockfile: the flags keep the devDependencies the build needs), and the build is your build script, like npm run build. The Node.js version comes from .nvmrc, .node-version, mise.toml or engines in package.json, else ox uses Node.js 24.

A page is also served without its .html: guide/intro.html answers at /guide/intro (VitePress' cleanUrls needs this), unless /guide/intro is a file or a folder of its own.

A start script changes the plan: ox then runs it as an app instead of serving files. Remove it, or write the ox.toml below. A start script that only runs a development server, like vite, react-scripts start or ng serve, is never run.

The ox.toml for a static site

Write one when you want your own domain, or when detection got the folder wrong. Pick the one for your site and put it at the root of the repo.

A Vite single-page app

ox.toml for a Vite app
domains = ["www.example.com"]

[static]
dir = "dist"
spa = true

[tools]
node = "24"

An Astro site

ox.toml for an Astro site
domains = ["www.example.com"]

[static]
dir = "dist"

[tools]
node = "24"

Plain HTML

ox.toml for plain HTML
domains = ["www.example.com"]

[static]
dir = "."

What each key does:

  • domains is the name Caddy serves your site on, with HTTPS.
  • [static] dir is the folder Caddy serves, relative to the repo root. It must exist after the build.
  • [static] spa sends every path with no file to index.html, so your app's own router handles links like /pricing. If your build prerenders pages (vite-ssg with dirStyle: 'nested'), each page in a folder, like story/index.html, answers at /story, and a 200.html beside them is sent instead of index.html for every other path. A page named story.html is not served at /story in a single-page app. Leave it out for Astro and plain HTML, where each page is its own file. A missing file like /favicon.ico is a 404, never index.html, and a 404.html in the built folder is shown for it.
  • [tools] node pins the Node.js version for the build.

The install and build still come from your lockfile and package.json. Set [build] commands only when your build is not the build script.

With dir = ".", Caddy serves every file in the repo, so keep anything private out of it, or move the site into a folder such as public and set dir = "public".

Services and an API next to the site

A static site needs none, so leave [services] out. If your site calls an API, that API is its own project, or the same repo with an [app] and api = ["/api"] under [static] (see the config reference).

Build-time variables such as VITE_API_URL

Your variables are there during the build, so a Vite app can read VITE_API_URL with import.meta.env.VITE_API_URL. Set it on the dashboard's Variables tab, or with ox vars set <project> VITE_API_URL. A changed value needs a new build, which Save & deploy starts.

Everything the build puts into your files is public, because every visitor downloads them. Never put a secret key in a static site.

Check and deploy the static site

Run ox check in the repo. It works offline and lists the plan. For the Vite file above it prints:

ox check for a Vite app
  static.dir                 dist                                                 declared
  build.install              npm ci --include=dev                                 detected:package-lock.json
  build.commands[0]          npm run build                                        detected:package.json
  tools.node                 24                                                   declared
  domains[0]                 www.example.com                                      declared

  Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, PUBLIC_SCHEME, PUBLIC_PORT

Ready to deploy.

Then ship it:

Ship from a terminal
ox check
ox deploy <project> --wait
ox runs <project>                 # past deploys, with their ids
ox logs <project> --run <id>    # one deploy's log

ox deploy --wait prints the build as it runs and exits with its result. A static site has no app process, so ox logs <project> --follow says so and points you to ox runs, and the dashboard's Logs tab points to the Deployments tab. Read a past build with --run instead.

If the static site deploy fails

  • the build left no dist folder: the build wrote its files somewhere else. Set [static] dir to the folder your build makes, like build for Create React App or out for a Next.js static export.
  • nothing to run: ox found no start command, static site, worker, or cron job: ox did not see a site, such as a generator whose config file is missing. Add [static] dir; you do not need an [app] start for a static site.
  • build 1/1: npm run build: exit status 1: your build failed. The lines above it are your build's own output. Run npm run build on your computer, fix it, and push.

Your visitors never see a failed deploy: the previous release keeps serving. Troubleshooting explains each message, and the config reference lists every [static] key.

Next steps

New to running your own server? How ox self-hosts your apps gives the overview, and the blog's deploy guides walk through a setup by hand.