Recipe: static site
What you get
You get a single HTML page on an HTTPS URL, and you run no process. The
static template gives the files that you commit to stock nginx, and mounts
them read-only. That is the whole app: no build step, no start command and no
process of your own to keep in service.
The example is live at recipe-static-docs.xhostd.com. It is the one file below, and the transcript in this guide deployed it. The page is read-only, and there is nothing behind it to write to. If you do the recipe on your own account, you get the same page under your own name.
The files
One file, at the repo root.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Static landing — xhost recipe</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0; min-height: 100vh;
display: grid; place-items: center;
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background: #0b0d12; color: #e6e9ef;
}
main { max-width: 34rem; padding: 2rem; }
h1 { font-size: 1.75rem; margin: 0 0 .5rem; letter-spacing: -.02em; }
p { margin: 0 0 1rem; color: #9aa4b8; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .9em; background: #171b24; padding: .15em .4em; border-radius: 4px;
}
a { color: #7aa2f7; }
</style>
</head>
<body>
<main>
<h1>It's live.</h1>
<p>
This page is the whole app. The <code>static</code> template serves the
files in your repo as-is through nginx — no build step, no start command,
no process of your own.
</p>
<p>
<code>index.html</code> at the repo root is what gets served at
<code>/</code>. Add more files beside it and they are served at their
matching paths.
</p>
<p><a href="https://docs.xhostd.com/guides">xhost recipes</a></p>
</main>
</body>
</html>
nginx serves index.html at the repo root as /. It serves any other file
that you commit beside it at the same path.
The deploy
Every app owns a git repo. git push, then deploy, is the standard
path. A push sends only the diff, which makes the second edit, the tenth
edit and the hundredth edit cheap. git transfers the few lines that changed.
It does not send the full contents of every file through a tool call. Use
commit_files in one situation only: git is not available on the machine that
you work on.
With both methods, you store the code in one step and you ship it in another
step. A push stores your code; it does not deploy the code. deploy is a
separate call, so you name the commit that goes live.
Four steps, in order.
1. Create the app. This call provisions the prod channel, its hostname
and its git repo.
create_app(name="recipe-static", template="static")
→ {"id": "aea6786c-52ff-4ed3-bf07-ab3050a42069",
"name": "recipe-static",
"template": "static",
"repo_url": "https://git.xhostd.com/docs/recipe-static.git",
"channels": [{"id": "4e8973a5-2a78-4326-bd7b-f95506d84b9f",
"name": "prod",
"hostname": "recipe-static-docs.xhostd.com",
"current_sha": null}], ...}
Keep two ids: the app's id, and the id of the new prod channel. Every
later call takes one or both. A new app is not online: the hostname serves
nothing until a deploy completes. repo_url is the repo that you push to. If
you lose it, get_app returns it again.
2. Mint a credential. One token is your git password, your Postgres password and your platform API bearer. It is valid for 30 days.
get_credentials()
→ {"token": "xh_...", "username": "docs",
"expires_at": "2026-08-30T17:26:27Z",
"scopes": ["blob:*", "deploy:*", "repo:*", "db:*", "channel:*"]}
Put the token in the password field of the remote URL. This detail is important:
https://<username>:<token>@git.xhostd.com/<username>/<app>.git
3. Clone, commit, push. A new xhost repo is empty, and git tells you so. The warning is correct; it is not a fault:
$ git clone https://docs:$XHOST_TOKEN@git.xhostd.com/docs/recipe-static.git
Cloning into 'recipe-static'...
warning: You appear to have cloned an empty repository.
Write index.html into that directory. Then commit and push it:
$ cd recipe-static
$ git add -A
$ git commit -m "add landing page"
$ git push origin master
Do not put the token in a file that you commit, or in text that you paste.
$XHOST_TOKEN above holds the value from get_credentials. A remote URL with
a real token in it stays in .git/config, in your shell history and in the
output of every git remote -v.
4. Deploy the branch.
deploy(app_id="aea6786c-52ff-4ed3-bf07-ab3050a42069",
channel_id="4e8973a5-2a78-4326-bd7b-f95506d84b9f",
ref="master")
→ {"deploy_id": "bbd8effc-a9f2-4b57-a5a1-dcdf830a3861",
"channel_id": "4e8973a5-2a78-4326-bd7b-f95506d84b9f",
"status": "queued"}
ref is a branch name. xhostd resolves it to the current head of that branch,
so after a push you do not need the sha. deploy also accepts sha when you
want an exact commit, and sha has priority if you pass both.
deploy returns as soon as it queues the deploy, and the work continues in
the background. Use get_deploy_log to follow it.
Verify it
get_deploy_log(app_id="aea6786c-52ff-4ed3-bf07-ab3050a42069",
channel_id="4e8973a5-2a78-4326-bd7b-f95506d84b9f",
deploy_id="bbd8effc-a9f2-4b57-a5a1-dcdf830a3861")
This log comes from that deploy. It has short ids, and it does not include the nginx entrypoint lines:
[2026-07-31T17:30:18+00:00] deploy begin id=bbd8effc-... channel=4e8973a5-... sha=206b9413...
[2026-07-31T17:30:18+00:00] git_sync ok: synced app=aea6786c-... channel=4e8973a5-... sha=206b9413...
[2026-07-31T17:30:18+00:00] start_static_site template=static
[2026-07-31T17:30:19+00:00] start_static_site ok: container=c20e29eec53c...
[2026-07-31T17:30:19+00:00] health_check container=c20e29eec53c... port=80 timeout=10.0s
[2026-07-31T17:30:19+00:00] health_check ok
[2026-07-31T17:30:19+00:00] [container] 2026/07/31 17:30:19 [notice] 1#1: nginx/1.31.3
[2026-07-31T17:30:19+00:00] [container] 2026/07/31 17:30:19 [notice] 1#1: start worker processes
[2026-07-31T17:30:19+00:00] [container] 10.77.1.5 - - [31/Jul/2026:17:30:19 +0000] "GET / HTTP/1.1" 200 1297 "-" "Python-urllib/3.13" "-"
[2026-07-31T17:30:19+00:00] caddy ensure_route hostname=recipe-static-docs.xhostd.com upstream=10.77.1.5:32005
[2026-07-31T17:30:19+00:00] caddy ensure_route ok
[2026-07-31T17:30:19+00:00] pinned deployed sha 206b94135aaa92b47421e30a1b86efc6d6ed824f
[2026-07-31T17:30:19+00:00] deploy success
The last line is the important one: deploy success. Read the log again until
it ends with that line, or with a line that names the failure.
Learn three points from that log.
The log has no [build] line. The deploy builds nothing, because this
template has nothing to build. The deploy syncs your commit and gives the
files to nginx. The whole log covers one second. An app deploy or a docker
deploy of the same commit writes about twelve build lines and an image-size
report. This template writes neither.
health_check ... port=80 timeout=10.0s. The platform probes a static
deploy on port 80, and gives it 10 seconds. nginx is already in service, and
you have no process to start. The platform probes the app template and the
docker template on port 3000, and gives them 120 seconds for your own
process to start. The probe itself is the line "GET / HTTP/1.1" 200 1297
below, from the nginx access log: a static deploy is healthy only if GET /
answers 2xx or 3xx. That line is below health_check ok, not above it. The
cause is the batch: the platform drains the container output into the log a
short time after the probe.
caddy ensure_route. The platform points the hostname at the new
container only after the health check passes. This log is a first deploy, so
there was no previous container to remove. On a second deploy, the line
stop_and_remove old container=... comes after the platform moves the route.
Thus the previous version serves the site until the new version passes its
health check. A failed deploy leaves your site in service.
The next commands prove it against the live demo:
$ curl -sS -o /dev/null -w '%{http_code}\n' https://recipe-static-docs.xhostd.com/
200
$ curl -sS -o /dev/null -w '%{http_code}\n' https://recipe-static-docs.xhostd.com/nope
404
The 200 carries text/html and 1297 bytes from nginx/1.31.3. That is the
same 1297 as the probe's access line, because it is the same file. The 404
comes from nginx. This template maps a URL path to a committed file and does
nothing else. Thus a path with no file behind it gives a 404; nginx does not
serve index.html instead.
When it goes wrong
No index.html at the repo root
The health check asks nginx for /. nginx answers 404, and the deploy fails.
nginx started correctly, but there is no file at the path of the probe. Commit
an index.html at the repo root. Do not put it in a public/, dist/ or
site/ subdirectory.
A client-side route 404s
https://recipe-static-docs.xhostd.com/nope gives the nginx 404 above. A path
such as /dashboard gives the same 404 in a single-page app, although the
app's router expects to handle it. This template has no rewrite rule, and no
try_files fallback to index.html. nginx serves a path only if a committed
file is at that path, and answers 404 for all other paths. Write a real file
at each path that you link to. Or put the app on the app template, where
your own server decides what /dashboard means.
You expected a build step
There is none. The static template copies nothing, runs nothing and compiles
nothing. It serves the bytes that you commit. If a bundler makes your site,
run the bundler on the machine where you write the code. Then commit the
built output to the repo root.
You expected to run server-side code
The static template runs no process of yours. Thus it has no place for a
request handler, a database query or a background job. A page that needs one
of those belongs on the app template. See
Recipe: Node API on the app template
or
Recipe: Python API on the app template.