Docs menuDeploy a static site

Deploy a static site to a VPS: Vite, Astro, 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

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.

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.
  • Other generators: Docusaurus (build), Eleventy (_site), 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. A Nuxt app with both a build and a generate script gets a hint instead, because either could be meant.

For a build, the install comes from your lockfile (npm ci for package-lock.json, pnpm install --frozen-lockfile, yarn install --frozen-lockfile or bun install --frozen-lockfile), 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 start script changes the plan: ox then runs it as an app instead of serving files. Remove it, or write the ox.toml below.

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.

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

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 directory: 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