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.jsonwithviteas a dependency, abuildscript and nostartscript. ox builds it and servesdist, or thebuild.outDirinvite.config.*, as a single-page app. This needs no ox.toml. - Plain HTML: an
index.htmlat the root and nopackage.json. ox serves the whole repo as it is, with no build. This needs no ox.toml either. - Astro:
astroas a dependency and anastro.config.*file. ox builds it and servesdist, or theoutDiryour config names. An Astro config with a server adapter is not a static site, andox checksays so. - Other generators: Docusaurus (
build), Eleventy (_site), SvelteKit withadapter-static(build), Nuxt with ageneratescript (.output/public) and a Next.js static export (out). Each needs its dependency and its config file. A Nuxt app with both abuildand ageneratescript 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
domains = ["www.example.com"]
[static]
dir = "dist"
spa = true
[tools]
node = "24"An Astro site
domains = ["www.example.com"]
[static]
dir = "dist"
[tools]
node = "24"Plain HTML
domains = ["www.example.com"]
[static]
dir = "."What each key does:
domainsis the name Caddy serves your site on, with HTTPS.[static] diris the folder Caddy serves, relative to the repo root. It must exist after the build.[static] spasends every path with no file toindex.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] nodepins 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:
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:
ox check
ox deploy <project> --wait
ox runs <project> # past deploys, with their ids
ox logs <project> --run <id> # one deploy's logox 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] dirto the folder your build makes, likebuildfor Create React App oroutfor 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] startfor 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. Runnpm run buildon 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
- Set build-time variables from the dashboard or the CLI.
- Add your own domain with HTTPS: one A record, and Caddy gets the certificate.
- Read a deploy's build log with
ox logs <project> --run <id>. - Roll back a bad deploy to a kept release without a rebuild.
- Turn on a preview per branch to check a change before it goes live.