A small app to copy as the start of your own. It serves on the unix
socket hotserve hands it, answers a health check, runs a migration
before each new version starts, finishes its requests on shutdown, and
deploys from GitHub Actions with no stored secret.
hotserve's e2e suite
builds and deploys these files, from its examples/deno, on every
change to hotserve, so what is here works. Each stable release
publishes them as the template repository
hotserve-example-deno: Use this template there makes a
repository of your own, which deploys with no file edited.
| File | What it is |
|---|---|
main.ts |
The app: / and /health, on $SOCKET (or 127.0.0.1:8000 locally) |
migrate.ts |
The pre_start hook: runs before each new version starts |
deno.json |
Dependencies and the dev, bundle and deploy tasks |
scripts/bundle.sh |
Builds app.tar.gz, with the module cache inside it |
scripts/deploy.sh |
Tells the box to deploy a release URL (or pushes a local tarball), or to roll back |
.github/workflows/deploy.yml |
Build, publish a release, deploy — on every push to main; Run workflow rolls back. The box's address is the repository variable HOTSERVE_URL |
hotserve.caddy |
What the app needs from the box: the lines for its app block |
AGENTS.md |
The rules the box enforces, for you and your coding agent |
deno task dev # http://127.0.0.1:8000You need a Debian 13 server with hotserve installed and a DNS name pointing at it. Your first deploy is the whole path from a fresh server, with this app as its one fork; the three steps below are its second half.
1. Install Deno on the box, under /usr/local, as root. The app's
sandbox contains /usr and nothing else of the host's software, so a
Deno in a home directory would not exist
for it. Use the version in .deno-version, which is what the workflow
builds with:
v=v$(cat .deno-version) # from a checkout; on the box, type what the file says
f=deno-$(uname -m)-unknown-linux-gnu.zip
curl -fsSLO "https://github.com/denoland/deno/releases/download/$v/$f"
curl -fsSLO "https://github.com/denoland/deno/releases/download/$v/$f.sha256sum"
sha256sum -c "$f.sha256sum"
apt install -y unzip && unzip -o "$f" deno -d /usr/local/bin2. Add the app to the box's Caddyfile, with the lines from
hotserve.caddy inside its app block. In the box's
repo
that is <host>/Caddyfile, committed signed and pushed to main; the
box template ships the Node app's block, and this app's command,
pre_start and env lines replace those. On a box without one, it is
/etc/hotserve/Caddyfile, as root: edit, hotserve validate --config
it, systemctl reload hotserve. Either way the app's part is the
same, and a box repo's file keeps its box block and the deploy
host's box_webhook line as they are. Without those two, the file is:
{
admin unix//run/hotserve/admin.sock
liveswap {
artifact_allowlist api.github.com/repos/your-org/
app example {
# the lines from hotserve.caddy go here
deploy_trust github {
audience hotserve
claim repository your-org/example
claim ref refs/heads/main
}
}
}
}
example.com {
reverse_proxy {
dynamic liveswap example
}
}
deploy.example.com {
liveswap_webhook
}The copy on the box is the one that counts: hotserve never reads
hotserve.caddy from a deploy. (hotserve's e2e suite instead saves
the file on its box as /etc/hotserve/<app>.caddy and writes import
of that path in the block.)
3. Point the workflow at the box. No file needs editing: in your
copy's GitHub repository, add the repository variable HOTSERVE_URL,
set to https://deploy.example.com/example (Settings → Secrets and
variables → Actions → Variables, or
gh variable set HOTSERVE_URL -R your-org/example --body https://deploy.example.com/example).
Then push to main. A run without HOTSERVE_URL stops at its first
step, saying so, and publishes nothing. The URL must be https://:
the request carries the run's token, so scripts/deploy.sh refuses
any other scheme before it mints one (HOTSERVE_ALLOW_HTTP=1 lifts
that, for a local test box only). The workflow publishes the
tarball as a GitHub release and the box fetches it — so every deployed
version stays on GitHub. The box fetches the asset by its API URL with
the job's own token, so a private repo deploys the same way as a
public one, and the box's
artifact_allowlist api.github.com/repos/your-org/ is what admits it.
The job also sends the tarball's digest, taken before the upload, and
the box refuses the asset unless it hashes to the same.
The deploy step prints the app's status when the new version is live,
or why it was refused; a refused deploy leaves the old version
serving. A 401 means the box's deploy_trust for this app does not
accept the run — most often claim repository or claim ref names
another repository or branch — or that HOTSERVE_URL names an app
the box does not know, which answers the same 401. The step prints
the values this run minted its token with; the box's journal
(journalctl -u hotserve) names the app asked for and the check that
refused it, within the box's budget for failed authentications: ten a
minute from one address and a hundred a minute in all; past either,
a failure is not written until the minute passes, and the answer
stays the same 401 — except that the box failing to consult the
token's issuer is named once a minute whatever the budget, and a
re-run once the issuer is back goes through.
What a first deploy that worked looks like, from the laptop:
- The deploy step's output ends with the app's status JSON, holding
"current_version":"<the commit's first 12 characters>". curl https://example.com/answershello from <that version> (schema v1); the 503 from before the deploy is gone.ssh alice@box.example.com journalctl -t hotserve-exampleshows the app's own output — the migration's line, then whatevermain.tsprints. Theadmgroup the box README grants is what reads it.
A re-run of the same workflow run is refused with a 422: versions are immutable on the box, and that commit's version is already there. Push a new commit to deploy again.
A new env var, file path or network host is a new Deno flag, and the
flags live on the box, so that a compromised build cannot grant itself
more. Change them on the box before you deploy code that needs them:
the running version does not mind an extra permission, and the new
one will not start without it. Remove a flag after the code that
needed it is gone. Keep hotserve.caddy here in step, so the repo
says what the code expects.
A reload never restarts the running app; the new flags apply at its next launch (a deploy, or a restart after a crash). A rollback runs the older code with the flags on the box today.
Actions → deploy → Run workflow, with a version from the releases
page (the tag: a commit's first 12 characters) and "Use workflow from"
left on main — the box's deploy_trust pins that ref, so a run from
a tag or another branch is refused with a 401. The box relaunches
that release from its disk — the same start, health gate and cutover
as a deploy, and no build — so it is live in about twenty seconds, or
the run is red with the reason and nothing changed. A version the box
no longer holds is a 422: it keeps the newest five releases, plus the
running one. It runs the older code with the Deno flags on the box
today (see above). Roll forward the same way you deploy: push a commit.
Given a local tarball instead of a URL, scripts/deploy.sh pushes it
in the request body, so a laptop build needs no release. The token
comes from hotserve deploy-token, and hotserve's release binaries
are Linux only, so this is a path for a Linux machine that is not the
box (the signing key must stay off the box). With a deploy_trust local block
on the box (see
Deploy authentication):
deno task bundle
HOTSERVE_URL=https://deploy.example.com/example \
HOTSERVE_TOKEN=$(hotserve deploy-token --key deploy.key --audience hotserve) \
deno task deployThe version defaults to the commit, and versions are immutable on the
box, so that deploys once per commit. While tracked files have
uncommitted changes the script refuses the default rather than label
them with the commit (it checks the checkout when it runs, not the
build); set a version for an uncommitted build
(VERSION=wip-3 deno task deploy). A version is 1 to 64 letters,
digits, ., _ and -, not starting with ..