No description
  • Rust 84.7%
  • HTML 10.6%
  • Nix 4%
  • Dockerfile 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-02 15:00:32 -04:00
.forgejo/workflows feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
.sqlx feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
.vscode feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
crates feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
.dockerignore feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
.envrc chore: Initial commit 2026-07-29 12:07:12 -04:00
.gitattributes feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
.gitignore feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
bacon.toml feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
bun.lock feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
Cargo.lock feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
Cargo.toml feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
CLAUDE.md feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
compose.dev.yaml feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
compose.yaml feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
devenv.lock feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
devenv.nix feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
devenv.yaml chore: Initial commit 2026-07-29 12:07:12 -04:00
Dockerfile feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
package.json feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00
README.md feat: Rust rewrite (#2) 2026-08-02 02:33:21 -04:00

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_PATH is network storage.
  • A CLI and a web panel. sf for 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=8 on 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