Docs
How Girder works, how to install it on your own server, and how to run it day to day, by hand or through an agent.
Overview
Girder is a self-hosted PaaS: one static Go binary that turns a Linux server into a platform you deploy to with git push. Everything runs on your hardware. There is no hosted control plane and no per-app metering.
What happens when you push
- You push to Girder's built-in git server over SSH. Girder is always the deploy remote (build + ship). Hosted forges (GitHub, GitLab, Forgejo) are optional for code hosting and review — see GitHub, GitLab, Forgejo.
- A pre-receive hook builds your app from its Dockerfile (or Nixpacks) and streams the build log back to your terminal. A failed build rejects the push, so broken code never reaches production.
- The new container rolls out behind Traefik with a health check and zero downtime. If the health check fails, Girder rolls back to the previous image on its own.
- Postgres, Redis, S3, domains, TLS, cron jobs, and backups are platform primitives, one command each.
Multi-node works the same way: provision more servers from the CLI or dashboard, assign apps, or set a multi-node replica set. Same code path as single-node.
Requirements
- A Linux server with a public IPv4. Any provider works; a Hetzner cx23 or bigger is the reference setup.
- A domain whose DNS is managed by Cloudflare (free plan is fine), plus a Cloudflare API token with
Zone:DNS:Editon that domain. This is how Girder does automatic DNS and Let's Encrypt certificates. - A Tailscale account (free) and an auth key from the admin console. Tailscale is not optional: it is how you reach git, the dashboard, and every admin surface. Only ports 80 and 443 are ever public.
- Optional: a Hetzner API token. It enables the cloud firewall (80 and 443 only) and lets you provision extra nodes straight from the CLI.
Install
Download the release linked in your purchase email and copy it to your server (or build from source). Then run the installer as root:
# on the server, as root
$ ./girder init \
--domain apps.example.com \
--email you.example.com \
--cf-token <cloudflare-token> \
--cf-zone <zone-id> \
--tailscale-key <tskey-...> \
--hcloud-token <optional>
Missing flags are prompted for. The installer:
- installs Docker and Tailscale and joins your tailnet,
- locks the firewall to 80 and 443 only (ufw, plus the Hetzner cloud firewall with a token),
- installs and starts the Girder daemon as a systemd service,
- writes the config and points the wildcard
*.apps.example.comat the server.
Later updates are one command on the server: girder update downloads the latest release, verifies it against the published checksums, swaps the binary, and restarts.
License
Release builds check for a license at daemon start. Install the file attached to your purchase email:
$ girder license install /root/license.json
$ girder license status # customer, edition, update window
Your license is perpetual. When the included update year lapses, the software keeps running exactly as it is; only new updates stop until you renew.
Licenses carry an edition: standard (default; omitted on older files) or platform (multi-user features when they ship). The same binary runs both. Agencies hosting clients on Standard today: see the agency playbook.
Everyday use
Your first app
$ girder apps create myapp
$ cd myapp
$ git remote add girder ssh://git@your-server:2222/myapp.git
$ git push girder main
# → https://myapp.apps.example.com (real certificate, zero downtime)
The machine that runs git push must be on your Tailscale network (laptop, self-hosted runner, or a CI job that joins the tailnet). Port 2222 is not public on purpose.
The daily commands
- Services:
girder services add myapp postgres(orredis,s3) provisions the service and injects credentials as env vars on the next deploy. Multi-process apps share one database with--from. - Domains:
girder domains add myapp app.example.commaps a custom host with automatic DNS and TLS. Adownloadkind serves the app's bucket publicly. - Env vars: set per app, encrypted at rest. You can list names, never values. Changing env or service URLs requires a redeploy (push again, or rollback to the current sha) so containers pick up the new values. A plain
restartdoes not re-inject env. - App config: port, health path,
memoryMB/pidsLimit/cpus(docker resource caps; empty clears), deploy branch,buildArgs,deployHook(runs after health, before traffic flip),previews/previewsDB,rateLimit, multi-nodenodes. - Staging and previews: durable staging env, seed/scrub, idle sleep, promote, and PR preview hosts. See Staging and previews.
- Rollback:
girder apps rollback myapp <sha>returns to a previous image (and refreshes env from the secret store). - Cron:
girder jobs add myapp nightly --schedule "*/15 * * * *" --cmd "..."runs inside the app's container. - Backups: nightly by default,
girder backup nowon demand. Prove restores withgirder backup verify; a restore always lands in a scratch database first, never over live data. The dashboard shows the last backup window (how long dumps and restic took per database) so you can see capacity pressure early. - More nodes:
girder nodes create node-2provisions a server,girder apps assign myapp node-2moves the app, DNS included. Replica sets:apps config myapp nodes node-1,node-2. - Build node:
girder config set buildNode node-2runs Docker image builds on that machine instead of the control node (empty value = control). Builds push to the internal registry; control still orchestrates deploys and scans. - Deploy queue: one deploy runs at a time. Pushing the same app branch again drops older queued jobs for that branch so one app cannot fill the queue. The dashboard overview shows the queue; apps may show
queued (2/3). - Dashboard:
http://your-server:8080on the tailnet. Get the token withgirder api-token. Config covers secrets, PgBouncer, registry move, build node, Trivy enforce, and more. - Sanity check:
girder doctorverifies the whole installation. - Support bundle:
girder support-bundle(or dashboard Config) writes a redacted diagnostics archive for support. No secrets inside. Send it with a short description of the problem.
Postgres pooler (PgBouncer)
Optional. Enable from the dashboard Config page or girder config set pgbouncer true. Girder smoke-tests auth, then rewrites each app's DATABASE_URL to port 6432. Redeploy every app that uses Postgres so containers see the new URL. The same applies when you disable it (back to :5432). Transaction pooling is the default; apps that keep named prepared statements across transactions (some job runners) should stay on direct Postgres or use session pooling. Watch connection pressure on the Backup page (or GET /api/postgres/pressure): warn at about 70% of max_connections, critical at 90%.
Staging and previews
Girder has two kinds of non-production deploys. They look similar in the URL shape but serve different jobs.
- Staging is one durable environment per app: stable host, configurable branch (default
staging), optional own node, isolated database by default, promote to production. - Previews are ephemeral: every non-production branch (when
previewsis on) gets a short-lived host that goes away when the branch is deleted.
Where to find it in the dashboard
- Open the dashboard on your tailnet (default port 8080; token from
girder api-token). - Go to Apps and open an app.
- Scroll to the staging card for durable staging (enable, seed, sleep, promote, and related settings).
- In the same app detail, under general config, set
previewsand optionalpreviewsDBfor PR-style hosts. - Custom hosts: app domains can target production or staging when you map a domain.
Staging: how it works
Enable staging from the staging card (or CLI). Girder does not deploy until you push the staging branch.
$ girder apps staging enable myapp $ git push girder staging # → https://staging--myapp.<appsDomain> $ girder apps staging promote myapp # production redeploys from staging image $ girder apps staging seed myapp # copy prod DB → staging DB (optional scrub) $ girder apps staging sleep myapp # stop container; wake starts it again $ girder apps staging wake myapp
- Host:
staging--<app>.<appsDomain>(one DNS label under your apps wildcard). If the app has custom production domains, staging also attachesstaging.<domain>(e.g.cometx.net→staging.cometx.net;www.is collapsed). Opt out withstagingAutoDomains false. Explicit staging domains (dashboard env = staging) are still supported. - Branch: default
staging; change withstagingBranchon the card orgirder apps config myapp stagingBranch develop. - Node: optional
stagingNode/stagingNodes. Empty means the same placement as production. - Env: staging containers get
GIRDER_ENV=stagingandGIRDER_STAGING=1. - Database: isolated Postgres by default (role/db
<app>_stg). Production data is untouched. Opt in to share prod withstagingSharePostgres(dangerous; not recommended). - Redis: if the app has Redis, staging gets its own container (
platform-redis-<app>-stg) so queues and caches never touch production. Workers that borrow Redis from another app reuse that app’s staging Redis. - S3: if the app has object storage, staging gets bucket
<app>-stgwith its own keys. Production bucket is never written by staging deploys. Borrowers reuse the owner’s staging bucket. - Staging env: optional key/value overrides applied only to staging deploys (
stagingEnv, e.g.FEATURE_X=1,LOG_LEVEL=debug). - Seed on enable: optional
stagingSeedOnEnableruns seed-from-prod when you turn staging on (after creating the staging DB). - Stacks / workers: if a worker borrows Postgres with
services add worker postgres --from web, staging for the worker uses the web staging database so the stack shares one staging dataset. Dashboard stacks can enable/disable staging for mates and promote entire stack. - Seed from production: overwrites the staging DB with a dump of live production, then runs optional scrub SQL (
stagingScrubSQL), e.g. redacting emails. If the app has S3, also mirrors the production bucket into<app>-stg(staging objects are replaced; production is read-only). Does not write to production. - Idle sleep: set
stagingIdleSleepHours(e.g.24). After that many hours without activity (deploy, seed, or wake), Girder stops the staging container. Wake starts it again; push to the staging branch also redeploys.0or empty turns auto-sleep off. - Promote: redeploys production from the image currently running on staging. It does not copy the staging database to production. Use promote when the build is good; use seed when you want realistic data in staging.
CLI flags: girder apps staging enable --stack myapp also enables other apps in the same stack. girder apps staging promote-stack myapp promotes every staging-enabled stack mate (partial success is reported if one app fails).
Previews: how they work
Turn on previews for the app. Pushes to any branch that is not the deploy branch and not the staging branch build a side instance:
- Host:
<branch-slug>--<app>.<appsDomain>. - Lifetime: deleting the git branch removes the preview container.
- Database: by default previews share the app’s normal env (often production credentials). Set
previewsDBtotruefor an isolated Postgres per branch, dropped when the branch is deleted. Only apps that own Postgres get a preview DB (borrowers keep using shared URLs). - Env:
GIRDER_PREVIEW=1andGIRDER_PREVIEW_BRANCH=…; with isolated DB alsoGIRDER_PREVIEW_DB=1.
Staging and previews can both be enabled. Push order of preference: staging branch → production deploy branch → other branches as previews (when on). Staging and preview databases are not included in nightly backup coverage (they are disposable).
Quick comparison
- Staging: long-lived, one per app, promote image to prod, seed/scrub, optional sleep.
- Preview: short-lived per branch, no promote path, optional ephemeral DB, gone with the branch.
- Neither replaces the other. Use staging for a shared QA lane; use previews for PR noise.
Scale and ops
Girder is built for one operator (or a small team) with many apps, not a multi-tenant public cloud. These knobs keep the control plane light as you grow.
- buildNode:
girder config set buildNode <node>(or Config in the dashboard). Docker builds run on that node; images go to the internal registry. Empty string returns builds to the control node. Prefer a machine with spare CPU, not a reserved backup node. - registryNode / dataNode / defaultNode: move the image registry, shared Postgres/Redis/S3, and default app placement off the control node so control stays the brain (git, API, queue).
- Deploy queue: up to two deploys at once (different apps; one job per app). A newer push for the same app and branch drops older queued jobs.
config set pipelineWorkers N(1–4, restart daemon). Overview shows the queue. - Backup window: after each backup, the dashboard shows how long dumps and restic took, including per-database dump size and time. Use that to see when the nightly window is growing.
- Updates: when a newer release is published, the overview can show Update now (downloads the binary, verifies checksums, restarts the control plane). You can still run
girder updateon the node over SSH.
Support bundle
When something is wrong and you need help (or you are helping a customer), collect a redacted diagnostics archive. It is safe to email: no API tokens, no app env values, no secrets.key, no age-encrypted env files, no git objects.
$ girder support-bundle -data /opt/girder/data
# → girder-support-YYYYMMDD-HHMMSS.tar.gz
$ # or: dashboard → Config → download support bundle
- Includes: doctor output, version, public config, nodes, app config JSON, domains, backup last-run log and metrics, audit log tail, docker ps, disk use, postgres connection pressure, last probe timing, recent platform journal when available.
- Send with: what you expected, what happened, and roughly when it started.
- CLI equivalent of the dashboard download: same contents as
GET /api/support/bundle(auth required on the API).
GitHub, GitLab, and Forgejo
Girder does not replace your forge. It is the place that builds and runs the code. You can use GitHub, GitLab, Forgejo, or none of them. What never changes: a deploy is a git push to Girder's SSH remote on the tailnet.
Do you need Actions or runners?
No, not for deploying. The simplest setup is two git remotes and a push from your laptop (on Tailscale). CI is only useful when you want automated gates (tests, lint) or deploy-on-merge without a human at a keyboard.
- No CI: dual remotes. Push to the forge for backup/PR review; push to Girder to go live.
- With CI: a job that can reach the tailnet runs tests, then
git pushto Girder. That needs a self-hosted runner on the tailnet, or a hosted runner that joins Tailscale for the job.
Option A — Dual remotes (no runners)
Best default for solo and small teams. Hosted forge for issues and PRs; Girder for production.
$ girder apps create myapp $ cd my-repo $ git remote add origin git@github.com:you/myapp.git # or gitlab / forgejo $ git remote add girder ssh://git@<tailscale-ip>:2222/myapp.git $ git push -u origin main $ git push girder main # only the second push deploys
Day to day:
$ git push origin main # save / open PR on the forge $ git push girder main # deploy # or both: $ git push origin main && git push girder main
Authorize your laptop's SSH public key on Girder (dashboard → git keys). The forge never needs that key.
Option B — Deploy from GitHub Actions / GitLab CI / Forgejo Actions
Use this when you want “merge to main → tests → deploy” without pushing from a laptop. The runner must reach Girder on the tailnet.
Preferred: a self-hosted runner (or Forgejo/GitLab runner) installed on a machine already on your Tailscale network — a small always-on VPS, a home lab box, or the control node itself if you accept that trade-off. No public git port required.
Alternative: a hosted runner plus the official Tailscale action (GitHub) or equivalent, so the job joins the tailnet, pushes, then leaves. You still store a Girder deploy key as a secret.
Minimal GitHub Actions sketch (same idea on GitLab .gitlab-ci.yml or Forgejo Actions):
# .github/workflows/deploy.yml name: deploy on: push: branches: [main] jobs: deploy: runs-on: self-hosted # on your tailnet steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: tests run: npm test # or go test, etc. - name: push to Girder env: GIRDER_KEY: ${{ secrets.GIRDER_DEPLOY_KEY }} GIRDER_HOST: ${{ vars.GIRDER_TAILSCALE_IP }} run: | install -m 600 /dev/null ~/.ssh/id_ed25519 printf '%s\n' "$GIRDER_KEY" > ~/.ssh/id_ed25519 ssh-keyscan -p 2222 "$GIRDER_HOST" >> ~/.ssh/known_hosts git remote add girder "ssh://git@${GIRDER_HOST}:2222/myapp.git" || true git push girder HEAD:main
Create a deploy-only SSH key pair, add the public key on Girder (scoped to that app if you use ACLs), and store the private key as GIRDER_DEPLOY_KEY. Do not reuse your personal laptop key in CI.
Option C — Mirror / sync (advanced)
Some teams push only to the forge and mirror into Girder with a forge “push mirror”, a webhook, or a scheduled sync. That still ends as a push (or force-push) into Girder's repo. Prefer Options A or B unless you already operate mirrors: debugging a failed build is clearer when the deploy push is explicit.
What CI should not do
- Build Docker images in Actions and
docker pushinto Girder's registry as the main path — Girder already builds on push and streams the log. CI is for tests and the git push, not a second build system (unless you know you need it). - Open port 2222 to the world so GitHub-hosted runners can connect without Tailscale.
- Expect
git push originalone to deploy. Origin is the forge; Girder only deploys when it receives the push.
Security model
Only TCP 80 and 443 face the internet. Port 443 serves your apps; port 80 stays open to redirect to HTTPS and to keep plain-HTTP fetches like robots.txt and external health checks working. Everything else lives on your tailnet:
- Git over SSH (port 2222): public-key only, with per-key app ACLs. Reachable on the tailnet only.
- Dashboard and API (port 8080): tailnet-only and token-authenticated.
- Postgres, Redis, S3: reachable only from app containers and the tailnet. Optional PgBouncer on 6432 for pooling.
Your laptop joins the same tailnet, which is how you push code and open the dashboard without exposing a single admin port. Install Tailscale on any machine that should deploy or administer the platform (including CI runners that push to Girder).
On top of that: per-app Docker networks, an encrypted secret store, an optional per-app rate limit, a read-only Docker API for the ingress layer, security headers (HSTS, nosniff, referrer policy) on every app by default, and a hash-chained audit log of every mutating call.
MCP and agents
Girder ships an MCP server, girder-mcp, that exposes the entire control plane as tools. An agent connected to it can create apps, set env vars, provision databases, map domains, read logs, roll back deploys, run backups, and manage nodes.
Connect your agent
Add the server to your MCP client (Claude Desktop, Cursor, or any other):
{
"mcpServers": {
"girder": {
"command": "girder-mcp",
"env": {
"PLATFORM_API_URL": "http://<control-tailscale-ip>:8080",
"PLATFORM_API_TOKEN": "<output of: girder api-token>"
}
}
}
}
The URL is the control node's tailnet address, so the machine running the agent must be on the tailnet. The token never leaves your machines.
What the agent can do
- Apps and deploys: list, create, logs, rollback, restart, env vars
- Services: provision and share postgres, redis, s3
- Domains and DNS: map hosts, toggle proxying, upsert raw records
- Backups: run now, verify, restore to scratch, promote
- Nodes: create, scale, patch, power, reclaim disk
- Observability: health probes, uptime, the audit log
Agent skills
Two ready-made agent skills ship with Girder. One teaches an agent to install a new instance from a bare server, the other teaches it to deploy and operate apps. Install them into your agent and the whole lifecycle, from bare metal to rollback, becomes something you can delegate.
myapp with a postgres database" or "the last build failed, tell me why" are both one prompt once the MCP server is connected.