roost: Forge-agnostic self-hosted preview environments for pull requests
Table of Contents
Every PR that changes a website should have a URL you can click. Netlify and Vercel do this, surge.sh did it for static sites, and a few years ago I built the same thing out of S3 buckets. The S3 approach works, but it only serves static files and doesn’t do a proper cleanup and one ends up gluing lifecycle logic into every pipeline.
roost is my take on the same problem without a Kubernetes cluster and without a third-party account.
It gives every PR a URL like pr-123.preview.example.com, backed by a static directory or a running container, and takes it down when the PR closes.
Two binaries Link to this section
roostdis a daemon on a box you control. It owns a Caddy instance, provisions previews and reclaims them.roostis the client your CI calls.
That is the whole surface: deploy, destroy, list.
Architecture diagram
The control API listens on 127.0.0.1:7420 by default, so it only has to be reachable by your CI, not by the world.
Caddy asks roostd whether a hostname is a live preview before it mints a certificate for it, which keeps random subdomains from triggering certificate issuance.
The lifecycle Link to this section
Two independent teardown paths, so a missed webhook does not leak a preview forever.
The --ttl is the safety net.
Even if the pipeline that runs roost destroy never fires, the reaper sweeps expired previews on its own interval.
Quick start Link to this section
Delegate a wildcard to the daemon host:
*.preview.example.com A <daemon-host-ip>
Start the daemon (Caddy has to be installed and its admin API reachable):
export ROOST_BASE_DOMAIN=preview.example.com
export ROOST_TOKEN=$(openssl rand -hex 16)
roostd
Deploy a built directory from CI:
export ROOST_SERVER=https://roost.example.com
export ROOST_TOKEN=...
roost deploy --repo acme/app --pr 123 --dir ./dist --ttl 168h
--repo plus --pr lets roost derive the DNS label, so PR #1 in two different repositories never collide on a shared daemon.
destroy recomputes the same name from the same inputs.
Here is a run against a daemon on my local machine with ROOST_BASE_DOMAIN=preview.localhost, deploying this very blog’s public/ directory:
Containers, not just static files Link to this section
Static previews are an upload that Caddy serves directly. Container previews run an image and get reverse-proxied:
Two things to know before you point this at your app:
- The app must bind
0.0.0.0:<port>, not127.0.0.1, because roost publishes the port on the host and proxies to it. - Containers run with
--cap-drop ALLand--security-opt no-new-privileges. Images that expect tochowntheir cache directories at startup, such as the stocknginximage, fail on that. Use an unprivileged variant (nginxinc/nginx-unprivilegedworked immediately in the run above).
Wiring it into CI Link to this section
roost has no idea what a forge is.
There is no webhook receiver, no API client and no forge SDK anywhere in the daemon.
--repo and --pr are two strings that get folded into a DNS label, and --name skips even that if you would rather pick the label yourself.
Anything that can run a binary after a build can therefore drive it: Forgejo Actions, Crow, GitHub Actions, GitLab CI, Jenkins, or a shell script on your laptop.
The integrations below are a convenience, not a requirement.
Forgejo Actions:
# .forgejo/workflows/preview.yml
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
deploy:
if: ${{ github.event.action != 'closed' }}
runs-on: docker
container:
image: codefloe.com/pat-s/roost:latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist
- env:
ROOST_SERVER: ${{ secrets.ROOST_SERVER }}
ROOST_TOKEN: ${{ secrets.ROOST_TOKEN }}
run: roost deploy --repo "${{ github.repository }}" --pr "${{ github.event.number }}" --dir ./dist --ttl 168h
destroy:
if: ${{ github.event.action == 'closed' }}
runs-on: docker
container:
image: codefloe.com/pat-s/roost:latest
steps:
- env:
ROOST_SERVER: ${{ secrets.ROOST_SERVER }}
ROOST_TOKEN: ${{ secrets.ROOST_TOKEN }}
run: roost destroy --repo "${{ github.repository }}" --pr "${{ github.event.number }}"
The Crow CI integrations looks the same, two steps gated on pull_request and pull_request_closed.
TLS: pick your latency Link to this section
By default Caddy issues a certificate per preview host on first request. That is zero configuration, but the first visitor waits out an ACME challenge, typically tens of seconds after CI finished.
Point roostd at a DNS provider instead and it manages one wildcard certificate for the whole zone at startup, so a preview is reachable the moment its route registers:
export ROOST_BASE_DOMAIN=preview.example.com
export ROOST_ACME_DNS_PROVIDER=cloudflare
export ROOST_ACME_DNS_CONFIG='{"api_token":"cf-token-with-dns-edit"}'
export ROOST_ACME_EMAIL=ops@example.com
roostd
This needs a Caddy built with the matching caddy-dns module, since stock Caddy ships none:
xcaddy build --with github.com/caddy-dns/cloudflare
Slow providers (Hetzner is a known one) can fail DNS-01 validation because the CA checks from several vantage points before the record has spread.
ROOST_ACME_DNS_PROPAGATION_DELAY=2m fixes that.
Auth without a shared secret Link to this section
A shared ROOST_TOKEN is fine when the CI has central secrets.
It is less fine when every repository holds the same all-powerful token.
If your forge issues OIDC ID tokens, roostd can accept those instead:
export ROOST_OIDC_ISSUER=https://codefloe.com
export ROOST_OIDC_AUDIENCE=https://roost.example.com
roostd then verifies the token against the issuer’s JWKS and scopes the caller to its repository claim.
A workflow can only touch previews derived for its own repository, and nothing else.
The client just sends whatever ROOST_TOKEN holds, so the workflow sets it to the fetched ID token:
ROOST_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://roost.example.com" | jq -r .value)
export ROOST_TOKEN
roost deploy --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --dir ./dist
This is the one spot where the forge does matter: the token needs a repository claim, which Actions-style forges emit and others name differently.
The shared-token path has no such requirement.
Keeping a shared box sane Link to this section
Previews are small and cheap to create, but at some point they also add up. The following settings prevent this and provide resource abuse protections:
# reject deploys past this many live previews
export ROOST_MAX_PREVIEWS=50
# clamp any requested TTL
export ROOST_MAX_TTL=168h
# per container limits
export ROOST_CONTAINER_MEMORY=512m
export ROOST_CONTAINER_CPUS=1.0
export ROOST_CONTAINER_PIDS_LIMIT=256
# only pull images from these registries
export ROOST_ALLOWED_REGISTRIES=codefloe.com,ghcr.io
Status Link to this section
Binaries for linux/amd64 and linux/arm64 plus a multi-arch container image ship with every tag on codefloe.com/pat-s/roost.
Issues and ideas are welcome there.