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.
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.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. - A plain HTML site has 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. - An Astro site has
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. - A Jekyll site has a
Gemfilewithjekyll(orgithub-pages), itsGemfile.lock, a_config.yml, and a pinned Ruby (.ruby-version). ox installs the gems, runsjekyll buildin production and serves_site, or thedestinationyour config names. ox picks Jekyll only when the repo holds no other stack's app. With thegithub-pagesgem and norepository:in_config.yml, setPAGES_REPO_NWOtoowner/repo, because a release has no.gitto read the name from. - Other generators, with the folder ox serves: Docusaurus (
build), Eleventy (_site), Hexo (public, with a_config.ymland asourcefolder), VitePress (.vitepress/dist), SvelteKit withadapter-static(build), Nuxt with ageneratescript (.output/public) and a Next.js static export (out). Each needs its dependency and its config file. VitePress also needs abuildscript that is justvitepress build, orvitepress build docs, which servesdocs/.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 whosebuildscript runsnuxt buildis a server unless its config setsssr: 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
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.
A few details of spa = true:
- If your build prerenders pages (vite-ssg with
dirStyle: 'nested'), each page in a folder, likestory/index.html, answers at/story. A200.htmlbeside them is sent instead ofindex.htmlfor every other path. - A page named
story.htmlis not served at/storyin a single-page app. - A missing file like
/favicon.icois a 404, neverindex.html. A404.htmlin 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:
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:
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. 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] 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, 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. Runnpm run buildon 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
- 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.
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.