Recipe: ship without git
What you get
You get the same kind of static site as
Recipe: static site. Here
commit_files puts the whole site onto the app. commit_files is a tool
call with a {path: contents} map. The platform turns that map into one
ordinary commit on the app's own repo. There is no shell, no clone and no
push.
git push and then deploy is the standard path, and this recipe is not
that path. Use this recipe in one case only: git is not available. That
case is a runtime with no shell, such as the claude.ai connector, a Custom
GPT, or an MCP client that cannot run a command. If you can run git on the
machine where you work, obey
Recipe: static site instead.
Its four steps are the same on every template.
commit_files is not a lesser mode: it writes real commits with your name on
them. They go into the same repo that a push goes to, and a later
git clone shows them like any other commits. The cost is per edit. A
changeset sends the full text of every file that it names through the model's
context, and it does this each time. A push sends only the diff. On a
one-file first commit the two costs are about equal. At the tenth edit the
push is a few lines, and the changeset is the whole project again. That is
why git is always the first choice, whatever the size of the project.
The worked example is live at recipe-commit-files-docs.xhostd.com. It is the two files below, and the transcript in this guide deployed them. Git did not run in that transcript, on any machine, at any point.
The files
There are two files at the repo root: the page, and the stylesheet that the page links to. A later changeset names neither file, and the platform then leaves the stylesheet as it is.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shipped without git — xhost recipe</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>Shipped without git.</h1>
<p>
Every file on this page arrived through <code>commit_files</code> — a
single tool call carrying a <code>{path: contents}</code> map, which the
platform turns into one ordinary commit on the app's own repo.
</p>
<p>
That is the fallback path. When git is available, <code>git push</code>
is the one to use: it sends only what changed, so the tenth edit costs a
few lines instead of the whole project.
</p>
<p><a href="https://docs.xhostd.com/guides">xhost recipes</a></p>
</main>
</body>
</html>
style.css
: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; }
The static template serves index.html from the repo root at /. It
serves style.css at /style.css, which is the path in the page's <link>.
The template serves each committed file at its equivalent path, and it does
nothing else.
The deploy
This path keeps two acts separate, exactly as git does.
commit_files stores your code, but it does not deploy your code.
deploy is its own explicit call, and it takes the sha from the changeset.
This transcript shows that separation: it makes three commits, and it deploys
two of them.
Do these five steps in order.
1. Create the app. This call makes the prod channel, its hostname and
its git repo. You get a repo even if you never push to it.
create_app(name="recipe-commit-files", template="static")
→ {"id": "b882c472-2a7b-414a-994b-d9c7c3bcdcee",
"name": "recipe-commit-files",
"template": "static",
"repo_url": "https://git.xhostd.com/docs/recipe-commit-files.git",
"channels": [{"id": "17a905c3-9365-4c1e-be98-7823f83a2647",
"name": "prod",
"hostname": "recipe-commit-files-docs.xhostd.com",
"current_sha": null}], ...}
Keep two ids: the app's id, and the id of the new prod channel. Every
later call needs one or both. This recipe has no credential step.
commit_files uses the same authenticated tool session as every other call.
You thus make no token, store no token and paste no token. That is the one
advantage of the fallback path over the standard path.
2. Commit the whole site in one changeset. The files map is
{path: contents}. A string value writes the file at that path, and it
replaces the file if the file exists.
commit_files(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee",
message="the whole site, in one changeset",
files={"index.html": "<!doctype html>\n<html lang=\"en\">\n...",
"style.css": ":root { color-scheme: light dark; }\n..."})
→ {"sha": "0bf2770b0ab04b119bc4138faf2d9b1a270ab095"}
The transcript above shortens the two values. In the real call each value is the full text of its file from the section above, with all the newlines. That is the cost: both files went through the model's context in full, for one commit.
list_files reads the branch back. Call it after every changeset until this
path is familiar to you:
list_files(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee")
→ {"ref": "master", "sha": "0bf2770b0ab04b119bc4138faf2d9b1a270ab095",
"files": [{"path": "index.html", "kind": "blob", "size": 815},
{"path": "style.css", "kind": "blob", "size": 563}]}
3. Deploy that commit.
deploy(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee",
channel_id="17a905c3-9365-4c1e-be98-7823f83a2647",
sha="0bf2770b0ab04b119bc4138faf2d9b1a270ab095")
→ {"deploy_id": "df8750a5-ada3-45fe-b3b1-1543ec14873d",
"channel_id": "17a905c3-9365-4c1e-be98-7823f83a2647",
"status": "queued"}
Use sha on this path, not ref. commit_files returns the exact commit
that it wrote, so you deploy that commit. ref="master" is also valid, and it
resolves to the branch's current head. sha wins if you give both. deploy
returns as soon as it queues the deploy, and the work runs asynchronously.
Follow it with get_deploy_log.
4. Change one file, and only that file. The platform does not change a path that the map does not name. This changeset names one new path:
commit_files(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee",
message="add robots.txt",
files={"robots.txt": "User-agent: *\nDisallow:\n"})
→ {"sha": "9664363b9339eec27087c819f49c14c977799134"}
list_files(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee")
→ {"ref": "master", "sha": "9664363b9339eec27087c819f49c14c977799134",
"files": [{"path": "index.html", "kind": "blob", "size": 815},
{"path": "robots.txt", "kind": "blob", "size": 24},
{"path": "style.css", "kind": "blob", "size": 563}]}
That list is the reason for this step. The changeset named robots.txt
and no other path. index.html and style.css did not change, and they keep
the same sizes as after commit 1: 815 and 563 bytes. A changeset is a sparse
patch, not a full picture of the tree. The platform applies it on top of the
branch's current head. If you omit a file, the platform leaves that file as
it is. It never deletes the file. Send only the paths that change.
5. Delete it again. A null value removes the path.
commit_files(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee",
message="drop robots.txt again",
files={"robots.txt": null})
→ {"sha": "c16fdc6adcc7b64b72603a674c05aef8ca708972"}
list_files(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee")
→ {"ref": "master", "sha": "c16fdc6adcc7b64b72603a674c05aef8ca708972",
"files": [{"path": "index.html", "kind": "blob", "size": 815},
{"path": "style.css", "kind": "blob", "size": 563}]}
A null value is the only way to delete a path through a changeset. That is
why an absent path means "leave the file as it is". The two actions need
different forms, and a map holds one value per path. Then comes the second
deploy, of c16fdc6a:
deploy(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee",
channel_id="17a905c3-9365-4c1e-be98-7823f83a2647",
sha="c16fdc6adcc7b64b72603a674c05aef8ca708972")
→ {"deploy_id": "2acbf6ad-91b6-40ce-9e43-c3d1840ffb3b",
"channel_id": "17a905c3-9365-4c1e-be98-7823f83a2647",
"status": "queued"}
No deploy ever carried robots.txt. The file existed in one commit only,
in the repo and nowhere else. The site served commit 1 for that whole period.
The next deploy came after the file was gone. That result is not a fault. It
shows you that a commit is not a deploy. A git push with no deploy after
it gives the same result.
Verify it
get_deploy_log(app_id="b882c472-2a7b-414a-994b-d9c7c3bcdcee",
channel_id="17a905c3-9365-4c1e-be98-7823f83a2647",
deploy_id="df8750a5-ada3-45fe-b3b1-1543ec14873d")
That is the first deploy. The transcript shows short ids. It omits the nginx entrypoint lines and the routine per-channel lines for the network, the blob route and the channel row:
[2026-07-31T23:20:42+00:00] deploy begin id=df8750a5-... channel=17a905c3-... sha=0bf2770b...
[2026-07-31T23:20:42+00:00] git_sync sha=0bf2770b0ab04b119bc4138faf2d9b1a270ab095
[2026-07-31T23:20:42+00:00] git_sync ok: synced app=b882c472-... channel=17a905c3-... sha=0bf2770b...
[2026-07-31T23:20:42+00:00] start_static_site template=static
[2026-07-31T23:20:43+00:00] start_static_site ok: container=07e5fbe4b902...
[2026-07-31T23:20:43+00:00] health_check container=07e5fbe4b902... port=80 timeout=10.0s
[2026-07-31T23:20:43+00:00] health_check ok
[2026-07-31T23:20:43+00:00] [container] 2026/07/31 23:20:43 [notice] 1#1: nginx/1.31.3
[2026-07-31T23:20:43+00:00] [container] 2026/07/31 23:20:43 [notice] 1#1: start worker processes
[2026-07-31T23:20:43+00:00] [container] 10.77.1.5 - - [31/Jul/2026:23:20:43 +0000] "GET / HTTP/1.1" 200 815 "-" "Python-urllib/3.13" "-"
[2026-07-31T23:20:43+00:00] caddy ensure_route hostname=recipe-commit-files-docs.xhostd.com upstream=10.77.1.5:32050
[2026-07-31T23:20:43+00:00] caddy ensure_route ok
[2026-07-31T23:20:43+00:00] pinned deployed sha 0bf2770b0ab04b119bc4138faf2d9b1a270ab095
[2026-07-31T23:20:44+00:00] deploy success
The last line is the important one: deploy success. Poll the log until it
ends with that line, or with a line that names the failure.
git_sync is the first step, and it tells you the main fact. The deploy
fetches a commit from a git repo, exactly as it does after a push. At that
point nothing separates this deploy from a deploy after a push. The deploy
cannot see how the commit came into the repo.
The log holds no [build] line. This is the static template. It builds
nothing, it gives the committed files to nginx, and the whole log covers two
seconds. health_check ... port=80 timeout=10.0s is the static template's
window. nginx already runs, so the probe waits for no process of yours. The
probe itself is the "GET / HTTP/1.1" 200 815 line, from nginx's own access
log. Recipe: static site
reads a static deploy log in full.
The second deploy's log is the same log with two more lines. The first deploy does not print them, because it replaces no container:
[2026-07-31T23:26:36+00:00] caddy ensure_route ok
[2026-07-31T23:26:36+00:00] stop_and_remove old container=07e5fbe4b902...
[2026-07-31T23:26:36+00:00] stop_and_remove ok
07e5fbe4 is the container from the first deploy. xhostd retires it only
after the new container passes its health check, and after the hostname
points at the new container. A failed redeploy thus keeps the previous
version live.
Then the proof, against the live demo:
$ curl -sS -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://recipe-commit-files-docs.xhostd.com/
200 text/html 815
$ curl -sS -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://recipe-commit-files-docs.xhostd.com/style.css
200 text/css 563
815 and 563 are the byte counts of the two files in this guide. Both bodies are byte-identical to those files. The number 815 is also in the health probe's access line above, because nginx serves the same file there.
A later git clone of https://git.xhostd.com/docs/recipe-commit-files.git
shows the three commits above, in order. Its working tree is byte-identical
to the two files here. That is the last fact about commit_files: it makes
ordinary commits, on the same repo that a push uses. On a machine with git,
you clone that repo and continue your work.
When it goes wrong
The app is connected to GitHub
commit_files is refused outright, with
this app is GitHub-connected; xhost mirrors this repo read-only. Push to GitHub instead — changes sync to xhost automatically.
There is no way around this refusal. On a connected app, GitHub is the
source, and the xhost repo is a read-only mirror of GitHub. Send the code to
GitHub, and xhost syncs it from there. This guide describes the message and
does not capture it. The demo app has no GitHub source, and a connection to
GitHub would stay in place. sync_git is not the answer either. It refreshes
that mirror, and it does not put code onto an app.
The commit succeeded but the site did not change
commit_files returned a sha and did no more. It stores code, but it does
not deploy code. Call deploy with that sha. A push to git with no deploy
gives the same result, so this failure occurs on both paths. If you do not
know which commit is live, call get_app for the channel's current_sha.
Call list_files for the content of the repo. If the two disagree, the repo
is ahead and a deploy is absent.
You send the whole tree on every edit
It works. The platform applies every changeset on top of the branch head, so
a file that you send again with no change does no damage. But it costs you
the text of the whole project on every edit. Send only the paths that change.
If git is available on the machine where you work, stop with commit_files.
Recipe: static site gives
the four steps, and a push sends the diff, not the project.
The file you need to commit is not text
The values in files are strings, and the platform writes them into the repo
as UTF-8. There is no binary form and no base64 alternative. An image, a
font, a favicon or a zip file cannot go through commit_files. A static site
with such a file needs git, or a URL to a file on another host. This guide
describes this limit and does not capture it, because the worked example is
two text files. The platform also checks each path. A changeset path must be
relative, and it must contain no . or .. segment.