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, and runs your build. Caddy then 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 and knows these sites:

  • A Vite app has 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.
  • A plain HTML site has 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.
  • An Astro site has 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.
  • A Jekyll site has 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. ox picks Jekyll 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, because a release has no .git to read the name from.
  • Other generators, with the folder ox serves: 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 also needs a build script that is just vitepress build, or vitepress build docs, which serves docs/.vitepress/dist. ox uses Hexo and VitePress 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 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. These flags install the devDependencies the build needs. 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. With none of them, ox uses Node.js 24.

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

A page is also served without its .html, so guide/intro.html answers at /guide/intro. VitePress' cleanUrls needs this. It does not apply when /guide/intro is a file or a folder of its own.

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. Leave it out for Astro and plain HTML, where each page is its own file.
  • [tools] node pins the Node.js version for the build.

A few details of spa = true:

  • If your build prerenders pages (vite-ssg with dirStyle: 'nested'), each page in a folder, like story/index.html, answers at /story. 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.
  • A missing file like /favicon.ico is a 404, never index.html. A 404.html in the built folder is shown for it.

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. 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 no services, so leave [services] out. If your site calls an API, make that API its own project. Or keep it in the same repo with an [app], and add api = ["/api"] under [static] (see the config reference).

Build-time variables such as VITE_API_URL

Your variables are set 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. Read a past build with ox logs <project> --run <id>. A static site has no app process, so ox logs <project> --follow says so and points you to ox runs. The dashboard's Logs tab points you to the Deployments tab.

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, for example because a generator's config file is missing. Add [static] dir. A static site needs no [app] start.
  • 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.

If an older release is live, it keeps serving while you fix any of these. Troubleshooting explains each message, and the config reference lists every [static] key.

Next steps

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