Skip to content

Repository files navigation

Furnace

A pull-based deployment agent for VPS servers. You SSH in once to bootstrap it, then never again — the worker polls your container registry, verifies signatures with Sigstore, and deploys apps automatically.

The problem: deploying to a VPS typically requires push-based webhooks, SSH keys, or CI runners with persistent inbound access. These are hard to scope, easy to leak, and add operational overhead.

The Furnace approach: the worker polls GHCR for new image tags. When it finds one, it verifies the image's Sigstore signature (checking the GitHub Actions OIDC identity of the signing workflow) and runs docker compose up. Public images need no registry secret; private images can use an encrypted systemd credential.

Contents

Architecture

┌─────────────────────────────────────────────────────┐
│ VPS (Docker host)                                   │
│                                                     │
│  caddy_net (Docker bridge network)                  │
│  ├── caddy (:80, :443)                              │
│  │     reverse_proxy furnace-web-1:8080             │
│  │     reverse_proxy myapp-web-1:8080               │
│  ├── furnace-web-1   (furnace web container)        │
│  └── myapp-web-1     (app web container)            │
│                                                     │
│  furnace worker (systemd host binary)               │
│    polls GHCR → verifies Sigstore → compose up      │
└─────────────────────────────────────────────────────┘
  • All containers (Caddy, furnace-web, apps) share caddy_net, a Docker bridge network created by furnace init.
  • Caddy routes each domain to {app}-web-1:{port} using Docker container DNS — no host-port publishing needed.
  • Worker runs as the host-installed furnace binary under systemd. It uses the system Docker daemon to run docker compose commands.
  • furnace-web is a separate container image deployed by the worker like any other app.

How It Works

Poll cycle (every poll_interval)

worker
  │
  ├─ for each app in the database:
  │    1. List tags from GHCR matching tag_pattern
  │    2. Find the newest semver tag not yet deployed
  │    3. If image digest matches last deploy AND artifact digest matches → skip
  │    4. If image changed: verify Sigstore signature of the new image
  │    5. Fetch compose artifact from OCI registry; verify artifact signature
  │    6. Stage artifact files in a new .furnace/releases/.staging-* directory
  │    7. Commit staging → rename to digest-based release directory
  │    8. docker compose pull && docker compose up -d --remove-orphans (from staged release)
  │    9. Poll container HEALTHCHECK via Docker API until healthy or timeout
  │   10. On success: flip current symlink to new release; prune old releases
  │   11. On failure: mark release .furnace-bad; current symlink unchanged (instant rollback)
  │
  └─ sleep poll_interval, repeat

Compose topology is always fetched from the OCI artifact configured per app and staged in .furnace/releases/ before each deploy. The current symlink points to the active release and only moves after a successful health check — if anything fails after that point, the symlink still points to the last known-good release.

The /v1/apps/{app}/deploy hint endpoint lets an app's release workflow signal the worker to check immediately, reducing typical deploy latency from poll_interval to seconds. It requires no credentials — it is a hint, not a trigger.

App release workflow

GitHub Actions (on release)
  │
  ├─ Build and push image to GHCR
  │    ghcr.io/org/app:v1.2.0
  │
  ├─ Sign image with Sigstore (cosign)
  │    Identity: github.com/org/app via OIDC
  │
  └─ POST /v1/apps/myapp/deploy  (hint — no auth required)
       → worker polls immediately

App Convention

Furnace is purpose-built for apps that follow the foundry/starter pattern. Each app directory (dir in config) holds the runtime state for deployed releases:

Path Purpose
.deploy.env Written by furnace on each deploy: APP_IMAGE=ghcr.io/org/app@sha256:abcdef...
.secrets/ Docker secrets directory (DATABASE_URL, etc.)
.furnace/releases/ Release directories managed by furnace; compose files are fetched here from the OCI artifact
.furnace/current Symlink to the active release directory

Compose files (docker-compose.yml, etc.) are fetched from the configured OCI artifact on every deploy — the operator does not provision them manually.

Compose services must attach to caddy_net so Caddy can reach them by container name:

# docker-compose.yml (excerpt)
services:
  web:
    image: ${APP_IMAGE}
    networks:
      - caddy_net

networks:
  caddy_net:
    external: true

Installation

Furnace needs a Linux VPS with Docker. You only need to SSH in once.

1. Install the binary

The installer downloads the binary, verifies its Sigstore signature against the Rekor transparency log, then installs it. Docker must be present — it is used to run cosign for verification and is also required to run furnace.

One-liner:

curl -fsSL https://raw.githubusercontent.com/go-sum/furnace/main/bin/install.sh | sudo bash

Inspect before running:

curl -fsSL https://raw.githubusercontent.com/go-sum/furnace/main/bin/install.sh -o install.sh
less install.sh
sudo bash install.sh

What install.sh does, step by step:

Step Action
1 Detects CPU architecture (amd64 / arm64)
2 Fetches the latest release tag from the GitHub API
3 Downloads furnace-linux-<arch> and furnace-linux-<arch>.bundle from the release
4 Runs cosign verify-blob inside a cgr.dev/chainguard/cosign container — verifies the binary was signed by a GitHub Actions workflow in go-sum/furnace, that the certificate was issued by GitHub's OIDC provider, and that a valid Rekor transparency log inclusion proof exists
5 Installs the binary to /usr/local/bin/furnace only if step 4 passes

If the signature check fails, the script exits before touching /usr/local/bin. The bundle file (certificate + transparency log entry + signature) is a Sigstore artifact produced by cosign sign-blob in the release workflow — the binary cannot be substituted without controlling the go-sum/furnace GitHub Actions OIDC identity.

furnace --version   # confirm install

2. Initialize the VPS

furnace init is idempotent — safe to run multiple times.

sudo furnace init

This creates:

    user `furnace`  # added to the `docker` group

    /var/lib/furnace/furnace.db      # SQLite database (seeded with defaults)
    /var/lib/furnace/furnace.db-wal  # WAL journal
    /var/lib/furnace/furnace.db-shm  # shared memory
    /srv/apps/ # app directories
    /srv/furnace/proxy/ # Caddy reverse proxy compose setup
    /srv/furnace/certs/ # TLS certificates
    caddy_net # Docker bridge network

furnace init seeds the database with default config values and a furnace-web app entry.

3. Configure the apps

App configuration is stored in the SQLite database at /var/lib/furnace/furnace.db. furnace init pre-seeds the database with a furnace-web app entry and default global config values — no manual config file editing is required for a standard setup.

Additional apps are managed via the furnace-web web interface (once it is running) or by inserting rows directly into the apps table. See Configuration Reference for the full schema.

4. TLS certificates

TLS is configured per app via the tls field. The default (false) assumes external TLS termination — Cloudflare Tunnel or another terminator handles HTTPS, and Caddy proxies plain HTTP internally.

Local certs — tls: true

Set tls: true on any app that needs a furnace-managed local certificate. Furnace generates an ECDSA P-256 CA and a server cert covering all tls: true app domains. The CA is installed to the system trust store so containers on the same host trust it.

apps:
  myapp:
    domain: "myapp.example.com"
    tls: true   # furnace generates local cert for this domain
sudo furnace mkcert --install      # generate CA, install to system trust store
sudo furnace mkcert                # generate cert for all apps with tls: true

To regenerate for specific apps only:

sudo furnace mkcert furnace-web myapp

If no apps have tls: true, furnace mkcert prints "no certs created" and exits cleanly.

External TLS — tls: false (default)

Leave tls unset or set it to false. Use a Cloudflare Tunnel or another external TLS terminator. No cert generation is needed.

5. Start everything

sudo furnace start

# Or with private registry support (e.g. ghcr.io private images):
echo "$GHCR_TOKEN" | sudo furnace start --credential-stdin

If ghcr.io/go-sum/furnace-web and its compose artifact are public, omit --credential-stdin. Use --credential-stdin only when your app image or compose artifact is private.

Minimum GitHub token permissions when --credential-stdin is needed:

  • Fine-grained personal access token: grant the token access to the owner/repositories that publish the GHCR package, then enable Packages: Read.
  • Classic personal access token: enable read:packages only.

The token value is a GitHub personal access token such as github_pat_... or ghp_....

This single command:

  1. Writes /etc/systemd/system/furnace-worker.service
  2. Runs systemctl daemon-reload
  3. Generates the Caddyfile and writes proxy files to /srv/furnace/proxy/
  4. Starts the Caddy container (docker compose up -d)
  5. Enables and starts the worker (systemctl enable --now furnace-worker)

The worker begins polling immediately. On the first cycle it will find and deploy all apps in your config, including furnace-web. This is the first end-to-end proof path: the host-installed furnace worker deploys the separate furnace-web container image and its signed compose artifact before managing any other apps.

6. Close SSH

All further updates happen automatically — the worker polls GHCR and deploys new releases as they appear. You can harden the VPS now: close unused ports and disable password authentication.

Adding an App

To add a new app after initial setup:

  1. Add the app via furnace-web (set tls: true if the app needs a local cert)
  2. Run sudo furnace proxy init to regenerate the Caddyfile
  3. Run sudo furnace proxy up to reload Caddy with the new route
  4. If tls: true, run sudo furnace mkcert to regenerate the server cert with the new domain
  5. Run sudo systemctl restart furnace-worker

The worker reads app configuration from the database at startup. Adding or removing apps requires a restart. furnace-web redeploys automatically on the next poll cycle.

The artifact field is required — compose topology is always fetched from the OCI artifact and Sigstore-verified on every deploy. The artifact must be signed with the same allowed_identity as the app image. Use {tag} as a placeholder for the image tag being deployed.

The Deploy Hint Endpoint

POST /v1/apps/{app}/deploy

No authentication. Signals the worker to check the registry for {app} immediately rather than waiting for the next poll interval. The worker still verifies the signature — the hint cannot bypass any security check.

Call it from your release workflow to reduce deploy latency:

curl -fsSL -X POST https://furnace.example.com/v1/apps/myapp/deploy

The endpoint returns 202 Accepted immediately; the actual deployment happens asynchronously.

Monitoring

Proxy status

furnace proxy status

Worker logs

furnace worker logs -f

Or directly via journalctl:

journalctl -u furnace-worker -f

Furnace emits structured JSON logs to stdout (captured by systemd).

App status

curl -s https://furnace.example.com/v1/apps/myapp/status
{
  "id": "01JVABCDEF1234567890ABCDEF",
  "app_name": "myapp",
  "image": "ghcr.io/yourorg/myapp@sha256:a1b2c3d4...",
  "status": "completed",
  "started_at": "2025-01-15T10:30:00Z",
  "ended_at": "2025-01-15T10:30:45Z"
}

Audit log

Every deployment start, success, and failure is appended to a JSONL file:

tail -20 /var/lib/furnace/audit/*.jsonl | jq .

Teardown

furnace reset removes everything furnace installed from the VPS. It requires root and prompts for explicit confirmation.

sudo furnace reset

What it removes:

  • Stops and disables the furnace-worker systemd unit
  • Removes /etc/systemd/system/furnace-worker.service and reloads systemd
  • Brings down the Caddy proxy (docker compose down)
  • Removes the caddy_net Docker network
  • Removes the system CA (/usr/local/share/ca-certificates/furnace-ca.crt) and runs update-ca-certificates
  • Removes all furnace directories: /etc/furnace, /var/lib/furnace, /srv/apps, /srv/furnace
  • Deletes the furnace system user

furnace reset is the inverse of furnace init + furnace start. It does not uninstall the furnace binary from /usr/local/bin.

CLI Reference

Command Requires root Description
furnace init yes Create system user, directories, seed database, and caddy_net network. Idempotent.
furnace start [--credential-stdin] yes Write systemd unit, start Caddy proxy, enable and start worker. Pass --credential-stdin to read a registry token from stdin.
furnace reset yes Remove all furnace state — inverse of init + start. Prompts for confirmation.
furnace mkcert --install yes Generate ECDSA P-256 CA, write to /var/lib/furnace/ca/, install to system trust store. Skips if CA already exists.
furnace mkcert [app...] yes Generate server cert for all apps (or named apps) from the database. Writes to /srv/furnace/certs/local.pem and local-key.pem. Requires CA from --install.
furnace proxy init no Regenerate Caddyfile and compose.yml from current app config. Requires Docker access.
furnace proxy up no Start (or restart) the Caddy container (docker compose up -d). Requires Docker access.
furnace proxy down no Stop the Caddy container (docker compose down). Requires Docker access.
furnace proxy status no Show Caddy container status (docker compose ps). Requires Docker access.
furnace proxy logs [-f] no Show Caddy container logs. Pass -f to follow. Requires Docker access.
furnace worker run no Run the furnace-worker poll loop (used by systemd). Handles graceful shutdown on SIGINT/SIGTERM.
furnace worker stop yes Stop the furnace-worker systemd unit.
furnace worker status no Show furnace-worker systemd unit status.
furnace worker logs [-f] no Show furnace-worker logs via journalctl. Pass -f to follow.

Proxy commands require Docker access (via docker group membership or root).

furnace worker run is the subcommand used by the systemd unit. The containerized HTTP app is shipped separately as the ghcr.io/go-sum/furnace-web image and runs the furnace-web binary directly.

Configuration Reference

Configuration is stored in the SQLite database at /var/lib/furnace/furnace.db, seeded by furnace init.

config table — global settings

Key Default Description
data_dir /var/lib/furnace Deployment records, audit logs, locks, env backups.
poll_interval 60s How often to poll each app's registry. The /deploy hint can short-circuit to 1s.
trusted_proxies 172.16.0.0/12 CIDR ranges of trusted reverse proxies for rate limiting. X-Forwarded-For from these IPs is used to identify the real client.

apps table — per-app config

Column Default Description
name (primary key) App name used in container DNS and CLI commands.
image required Base image path in GHCR (without tag).
tag_pattern required Glob pattern for tags to watch (path.Match rules). v* matches v1.0.0, etc.
allowed_identity required GitHub org/repo whose Sigstore identity must have signed the image.
artifact required OCI artifact reference for compose files. Use {tag} as a placeholder for the image tag being deployed.
domain required Public domain for Caddyfile generation. Must be lowercase (e.g. myapp.example.com).
dir /srv/apps/{name} Absolute path to the app directory on the VPS.
port 8080 Port the app's web container listens on. Caddy routes via container DNS: {name}-web-1:{port}.
tls false true → furnace generates a local cert; Caddy serves tls /certs/local.pem /certs/local-key.pem. false → relies on Cloudflare Tunnel or external TLS termination.
env_file .deploy.env Env file written by furnace on each deploy.
image_var APP_IMAGE Env variable in env_file set to the deployed image reference.
container (required) Docker container name for health checking via the Docker API.
health_timeout 30s How long to wait for the container HEALTHCHECK to report healthy after docker compose up.
keep_releases 5 Number of old release directories to retain under .furnace/releases/.

Health checking uses the Docker HEALTHCHECK API — the worker polls the container's health status directly rather than making HTTP requests to a URL.

Data Layout

/etc/furnace/
  registry-token.cred       # encrypted registry token (created by --credential-stdin)

/var/lib/furnace/
  furnace.db                # SQLite database (apps, deployments, config)
  furnace.db-wal            # WAL journal
  furnace.db-shm            # shared memory
  ca/
    ca.pem                  # furnace CA certificate (created by mkcert --install)
    ca-key.pem              # furnace CA private key (0600)
  state/
    myapp.json              # worker poll state (last polled version per app)
  audit/
    myapp.jsonl             # append-only JSONL audit log
  locks/
    myapp.lock              # flock-based concurrency lock
  envbackups/
    myapp/
      1705312200000000000.env  # .deploy.env snapshots (latest 10 kept)

/srv/apps/
  myapp/
    .deploy.env              # written by furnace on each deploy
    .secrets/                # Docker secrets directory
    .furnace/
      current -> releases/sha256-abc123.../  # symlink to active release; flips on successful deploy
      releases/
        sha256-abc123.../             # committed release (compose files fetched from artifact)
        sha256-def456.../             # previous release (retained per keep_releases)
        .staging-abcd1234/            # in-progress staging dir (cleaned up on failure)
        sha256-bad789.../
          .furnace-bad                # marker written when deploy fails health check

/srv/furnace/proxy/
  compose.yml               # Caddy Docker Compose
  Caddyfile                 # regenerated by furnace on app add/remove

/srv/furnace/certs/
  local.pem                 # TLS cert (created by furnace mkcert)
  local-key.pem             # TLS key (0600)

/usr/local/share/ca-certificates/
  furnace-ca.crt            # system trust store entry (created by mkcert --install)

Security Model

  • Pull-based, no inbound webhooks. The worker initiates all outbound connections to GHCR and Sigstore. No inbound network access is required beyond the hint endpoint.
  • Encrypted credential storage. Registry tokens are encrypted at rest via systemd-creds. At runtime the worker reads the decrypted token from CREDENTIALS_DIRECTORY and materializes an ephemeral Docker auth config in its private tmp area for docker compose pull; no persistent Docker auth file is stored under /var/lib/furnace.
  • Sigstore signature verification. Every image must be signed by a GitHub Actions workflow from the configured allowed_identity repository. The signature is verified against Sigstore's public transparency log (Rekor). An unsigned or incorrectly signed image is rejected before any deployment step runs.
  • Public mode needs no registry secret. Public images and artifacts use anonymous pulls. Private images can use an encrypted registry token loaded through systemd credentials. Signing identity is still verified cryptographically, not via the registry token.
  • OCI-sourced compose topology. Compose files are always fetched from the OCI artifact on every deploy and verified by cosign before any filesystem write. The artifact must be signed with the same allowed_identity as the app image — an operator cannot substitute compose files without controlling the signing identity. Files are staged atomically before the current symlink flips; a failed health check leaves the symlink pointing to the previous release, making rollback instantaneous.
  • Executor subcommand allowlist. The worker only permits docker compose subcommands. docker run, docker exec, and similar privileged primitives are rejected at the executor layer.
  • Domain validation. The domain field is validated against an RFC 1123 hostname regex at config load time, preventing Caddyfile directive injection via malformed domain values.
  • caddy_net isolation. Caddy and app containers share a single Docker bridge network. Containers are reachable only by name within the network; no host ports are published for app-to-Caddy routing.
  • Caddy container hardening. Caddy runs with read_only: true, cap_drop: [ALL], cap_add: [NET_BIND_SERVICE], no-new-privileges, and a tmpfs /tmp.
  • Distroless furnace-web image. The ghcr.io/go-sum/furnace-web image is packaged on cgr.dev/chainguard/static:latest — no shell, no package manager, no setuid binaries.
  • Systemd hardening. Worker unit includes: NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, scoped ReadWritePaths, CapabilityBoundingSet= (empty), SystemCallFilter=@system-service, PrivateDevices, RestrictAddressFamilies, LockPersonality, MemoryDenyWriteExecute, RestrictNamespaces, ProtectKernelTunables, ProtectControlGroups, RestrictSUIDSGID.
  • Flock-based deploy lock. One deployment per app at a time, survives process restarts.
  • Env file rollback. On failure, .deploy.env is restored so the next compose up uses the last known-good image.
  • Audit trail. All deployment events logged to append-only JSONL.
  • Self-signed CA via stdlib crypto. furnace mkcert generates ECDSA P-256 certificates using Go's crypto/x509 — no external tools or dependencies required. The CA key is stored at 0600. The system CA entry is removed by furnace reset.
  • Status endpoint exposure. The /v1/apps/{app}/status endpoint returns deployment metadata. Restrict access to trusted networks via firewall rules or a Cloudflare Tunnel access policy if your VPS is publicly reachable.

License

See LICENSE for details.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages