- Rust 84.7%
- HTML 10.6%
- Nix 4%
- Dockerfile 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .sqlx | ||
| .vscode | ||
| crates | ||
| .dockerignore | ||
| .envrc | ||
| .gitattributes | ||
| .gitignore | ||
| bacon.toml | ||
| bun.lock | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| compose.dev.yaml | ||
| compose.yaml | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| Dockerfile | ||
| package.json | ||
| README.md | ||
Static Forge
Self-hosted continuous deployment for static sites.
You (or CI) posts a .tar.gz to an API endpoint. Static Forge extracts it, swaps it into place atomically, and serves it. One binary is both the control panel and the web server for every site you deploy — no nginx or Caddy needed for the serving itself.
- Atomic deploys. A build is staged in full, then renamed into place. A site never serves a half-written tree.
- Immediate pass/fail. Extraction happens during the request, so the CI job that posted the archive learns whether it worked.
- Every attempt is recorded. Rejected deploys are stored too, with the reason.
- Parallel extraction. Files are written by a worker pool, which matters a lot when
SITES_PATHis network storage. - A CLI and a web panel.
sffor the terminal, a server-rendered panel on the control hostname for everything else.
How it works
Static Forge routes on the Host header.
| Request | Served from |
|---|---|
SERVER_NAME |
the control panel and API |
| anything else | SITES_PATH/{host} |
/healthz |
always the health endpoint, on any host |
So forge.example.com/api/sites hits the API, and my.docs.com/guide/ serves SITES_PATH/my.docs.com/guide/index.html. Point the control panel hostname and every site domain at the host.
A deploy runs as: stream the upload to SITES_PATH/.staging, extract it into SITES_PATH/.staging/{domain}, rename the live directory into .trash, rename staging into place, then delete the old tree in the background. The two renames are metadata-only, so the swap is the same speed for a 10 KB site and a 10 GB one.
It needs Postgres. /healthz round-trips a query, so an instance that has lost its database reports itself unavailable instead of merely alive.
Running it
The Docker image is published to gitgud.boo/javif89/static-forge (:latest, plus a tag per release). It runs as uid 10001, listens on 8080, and exposes /sites as a volume.
docker run -d --name static-forge \
-p 8080:8080 \
-e DATABASE_URL=postgres://user:pass@db:5432/static-forge \
-e SERVER_NAME=forge.example.com \
-e UI_PASSWORD=... \
-v /srv/sites:/sites \
gitgud.boo/javif89/static-forge:latest
If you bind-mount a host directory, chown it to 10001:10001 — the process does not run as root, on purpose, since deployed files are attacker-supplied.
Migrations are embedded in the binary and applied on every start, so there is nothing to run by hand. Concurrent instances take an advisory lock and queue rather than collide.
Environment
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL |
required | Postgres connection string. |
SERVER_NAME |
localhost |
Hostname the control panel and API answer on. Every other Host is treated as a deployed site. |
SITES_PATH |
/sites |
Where deployed sites live. Also holds .staging and .trash. |
SERVER_PORT |
80 |
Port to bind on 0.0.0.0. The Docker image sets 8080. |
UI_PASSWORD |
unset | Password for the web panel. Unset means the panel has no authentication, and CLI browser login is refused outright. |
SITES_MAX_UPLOAD_KB |
102400 |
Largest archive accepted. Larger uploads get a 413. |
SITES_MAX_EXTRACTED_KB |
10× the upload limit | Cap on the expanded size. The upload limit only bounds compressed bytes, and gzip will happily do 1000:1 on repetitive input. |
DATABASE_MAX_CONNECTIONS |
5 |
Postgres pool size. |
Everything is parsed once at startup, so a malformed value fails immediately rather than mid-request.
SITES_PATH must be a single filesystem. The atomic swap is rename(2), which fails across mount points, and staging and trash both live inside it for that reason.
The web panel
On SERVER_NAME: a dashboard listing sites and recent deployments, a per-site page with that site's deploy history, and token management. UI_PASSWORD gates it; there is no user table and no session table — the sf_session cookie holds a digest of the password itself, so changing UI_PASSWORD logs everyone out.
The CLI
sf is a separate binary, attached to each release as sf-<tag>-x86_64-linux. It talks to the same JSON API and never depends on the server crate, so you can install it anywhere without Postgres.
sf login https://forge.example.com # browser flow; mints and stores a token
sf sites # list
sf sites add example.com
sf sites rm example.com
sf tokens # list
sf tokens create ci # printed once
sf tokens rm 3
sf deploy example.com ./dist # a directory is packed for you
sf deploy example.com site.tar.gz # an existing archive is sent as-is
sf deployments # history, newest first
sf deployments example.com
sf login opens the panel's authorize page, which mints a token and hands it back over a loopback callback; approval is a POST, so no URL you merely load can create a token. It requires UI_PASSWORD to be set on the server. --token <t> skips the browser and stores a token you already have.
Config lives at $XDG_CONFIG_HOME/static-forge/config.toml (mode 0600 — it holds plaintext tokens) and can hold several servers. The first one you log into becomes the default; pick another with --server <name>. --json prints raw JSON instead of a table.
Deploying from CI
Registration is one-time — sf sites add example.com, or the panel, or POST /api/sites. Then have CI post the build:
tar -czf site.tar.gz -C dist .
curl -f -X POST https://forge.example.com/api/deployments/example.com \
-H "Authorization: Bearer $STATIC_FORGE_TOKEN" \
-F archive=@site.tar.gz
curl -f matters: it turns a rejected deployment into a non-zero exit so the CI job actually fails. The archive's contents land at the site root, so pack the contents of your build directory (-C dist .), not the directory itself. sf deploy example.com ./dist does the same thing and gets the tar flags right for you.
Symlinks and other non-regular entries in the archive are skipped, any path that would escape the site directory is rejected outright, and an archive that expands past SITES_MAX_EXTRACTED_KB is aborted mid-extraction.
API
Every /api route requires a bearer token. Tokens are returned once at creation; only their SHA-256 hash is stored.
| Method | Path | |
|---|---|---|
GET |
/api/sites |
List sites |
POST |
/api/sites |
Create a site |
DELETE |
/api/sites/{id} |
Delete a site |
GET |
/api/tokens |
List tokens |
POST |
/api/tokens |
Create a token, returns it once |
DELETE |
/api/tokens/{id} |
Revoke a token |
GET |
/api/deployments |
Deploy history, newest first |
GET |
/api/deployments/{domain} |
Deploy history for one site |
POST |
/api/deployments/{domain} |
Deploy an archive |
GET |
/healthz |
Liveness, unauthenticated, any host |
Errors come back as {"error": "..."} with a matching status.
Behind a reverse proxy
Static Forge speaks plain HTTP and expects TLS to terminate in front of it. It has no ACME support and won't get any — the assumption is you already run Caddy, Traefik, or similar.
Your proxy must preserve the original Host header, since that's what selects which site to serve. Caddy's reverse_proxy does by default. nginx does not — it needs proxy_set_header Host $host; explicitly.
Storage notes
Deploys are dominated by per-file cost, not bandwidth. On a spinning-disk NAS over NFS, expect roughly 15-20 ms per file, which a 150-file site turns into several seconds. Two things help:
nconnect=8on the NFS mount. Without it every write serializes over one TCP connection and the worker pool can't help.- Local storage. The same deploy that takes 6 s on network storage takes ~40 ms on an SSD.
Serving has the same property — every asset request pays that latency — so local disk is worth it for sites that get traffic.
Development
The dev environment is devenv, plus Docker for the local Postgres.
pg:up # start Postgres
db:init # create the database
db:migrate # run migrations
dev # tmux session: server with live reload + Tailwind watcher
The dev server listens on 0.0.0.0:8989. CSS is built separately with bun run css (or css:watch) into crates/server/static/, which is embedded into the binary — build it before compiling a release, or the panel will not render.
sqlx verifies every query against a live database at compile time. A committed .sqlx/ cache covers that for offline builds, so cargo check works without Postgres — but after any migration change, run cargo sqlx prepare --workspace and commit the result. Tests use #[sqlx::test], which creates a fresh database per test, so cargo test --workspace does need Postgres running.
Some helpers for poking at the API by hand:
api:make-site example.com
api:sites
api:make-token ci
api:deploy example.com site.tar.gz
sf sites # the CLI, against the local server