A secure-by-default runtime for self-hosted apps and AI agents. You describe what you want to run in one file, run one command, and Perch builds it, runs it under hardened defaults, gives it a web address with automatic HTTPS, and keeps it running — on infrastructure you operate. No managed-platform account, no monthly platform bill.
Agents are treated as a distinct risk: a single workload can be given its own
short-lived, least-privilege identity and hold no long-lived secrets. The identity
controls are opt-in and fully backwards compatible — off by default, and an
existing perch.yaml runs byte-for-byte as before. When enabled, a workload gets a
per-run cryptographic identity, a credential broker that issues scoped, short-TTL
database credentials in place of a permanent DATABASE_URL, attestation before
issuance, controlled egress, envelope-encrypted state at rest, and an authenticated
control-plane API. The full design and what each control does (and does not) cover
is in THREAT_MODEL.md.
- An app is a website or service (a dashboard, a tool, an API).
- An agent is a background helper (a Claude-powered assistant, a daily job).
- A managed service is a ready-made backend block Perch runs for you — a Postgres database, object storage, a cache, an auto-generated API, or auth.
- Secure by default: containers run non-root with dropped capabilities, and an agent can be given a short-lived, least-privilege identity instead of a permanent secret (opt-in; see Securing agents).
- You list these in one file (
perch.yaml), runperch up, and Perch wires them together and brings everything online. You own the box.
Plan on about 15 minutes. You need Docker installed and running, and
Python 3.10+. Perch is a Python tool — there is no npm install and nothing
to compile.
1. Install
curl -fsSL https://raw.githubusercontent.com/sknib1337/Perch/main/install.sh | bash
# or, from the repo: pip install -e .
# optional asymmetric crypto (Ed25519 identities + Fernet-sealed state):
# pip install -e ".[crypto]"2. Check everything's ready
perch doctorA simple pass/fail checklist; every ✗ comes with the exact fix.
3. Describe what to run — edit perch.yaml:
services:
- name: db
type: postgres # a database, vector-search ready
- name: web
type: webapp
build: { context: ./your-app } # the folder with your app (needs a Dockerfile)
port: 8080 # the port your app runs on
route: { host: web.localhost } # the web address to visit
bindings: [db] # your app gets DATABASE_URL automatically4. Turn it on
perch upPerch builds your app, starts the database, hands your app its connection details, and brings everything online — then prints the link.
5. (Optional) Put it on the internet — get a small always-on server (about
$5/month), point your domain's A record at it, set route.host to your domain,
and run perch up. The built-in proxy fetches a secure HTTPS certificate
automatically, and your site is live.
To watch everything in a dashboard, run perch serve and open
http://127.0.0.1:8787.
Workloads
- Apps (
type: webapp) — long-running services that receive traffic, routed with automatic HTTPS. - Agents (
type: agent) — background workers; continuous, or on a cron schedule via the built-in scheduler. - Functions (
type: function) — build and run your own handler code with per-function secrets and logs.
Managed backend services — provisioned from hardened official images, with generated credentials injected into the workloads that use them:
postgres— Postgres with vector search (pgvector), persistent storage, generated credentials, and scheduled backups.cache— Redis with append-only persistence.storage— S3-compatible object storage (MinIO) with bucket provisioning.rest-api— an auto-generated REST API (PostgREST) over apostgresservice.auth— identity (Zitadel): OIDC/OAuth, users, and sessions.
Workloads reference services under bindings, and Perch injects the connection
environment automatically (DATABASE_URL, REDIS_URL, S3_*, and so on).
One perch.yaml is the single source of truth for everything you run.
project: my-stack
defaults:
restart: unless-stopped
resources: { memory: 512m, cpus: "1.0" }
prune: false # true: remove managed containers not present in this file
services:
- name: db
type: postgres
version: "16"
extensions: [pgvector]
backup: { schedule: "0 2 * * *", retain: 7 }
- name: web
type: webapp
build: { context: ./web } # local path or Git URL
port: 8080
route: { host: app.example.com }
health: { path: /healthz }
bindings: [db]
env:
- { key: LOG_LEVEL, value: info }
- { key: API_KEY, value: "${API_KEY}", secret: true }
- name: report-agent
type: agent
build: { context: https://github.com/sknib1337/report-agent }
schedule: "0 */6 * * *" # omit for a continuous worker
bindings: [db]
env:
- { key: API_TOKEN, value: "${API_TOKEN}", secret: true }Secrets are ${ENV_VAR} references resolved when you deploy — never stored in the
file. Managed-service credentials are generated once and kept in git-ignored local
state (.perch/state.json).
Everything above runs an agent the simple way: a static, long-lived DATABASE_URL
and full outbound internet. That's fine for trusted code. When the workload is an
AI agent — code you don't fully control, reacting to untrusted input — you can
tighten it without changing the agent itself. Each control below is a few lines of
manifest, off unless you set it, and maps to a numbered control in
THREAT_MODEL.md.
- name: assistant
type: agent
build: { context: ./assistant }
bindings: [db, cache]
identity: # C1/C2 — per-run, scoped, short-TTL credentials
ttl: 900 # instead of a permanent DATABASE_URL password
scopes: { db: read } # least privilege per binding (default: write)
egress: { allow: [api.anthropic.com] } # C8 — default-deny outbound; only these hosts
mcp: # C9 — default-deny tool/MCP mediation (enforced by a gateway)
servers: { github: https://mcp.example.com/github }
allow: { tools: ["github.*"] } # only these tools reach the upstream server
verify: # C12 — supply-chain integrity
pin: true # image must be pinned to a @sha256 digest
registries: [ghcr.io, docker.io] # and pulled only from these registriesWhat each one does:
identity— instead of injecting a permanent database password, Perch's broker issues a fresh, scoped, short-TTL credential per run. The agent holds no long-lived secret; the credential expires on its own (ttl, capped at one hour), andscopesnarrows each binding toreadorwrite(defaultwrite, so enabling identity never silently breaks an existing writer). Issuance is gated by attestation (C3): the running config and build identity must match what's expected before any credential is minted.egress—denyremoves the workload's route off-box (it is placed on an internal Docker network with no route to the internet);{allow: [...]}permits only the listed hosts through a default-deny forward proxy. Bound managed services stay reachable. (Docker's embedded DNS resolver can still forward lookups upstream, so DNS is not a sealed channel — see THREAT_MODEL.md C8.) Omit the field for today's full outbound internet.mcp— points the agent's MCP client at a per-agent mediating gateway that authorizes every tool/resource/prompt call against a default-deny allowlist and forwards only what's allowed to theserversyou list (HTTP or local stdio).*/listresponses are filtered to the allowlist (the model never sees a disallowed capability), and server-initiatedsampling/completionare denied unless enabled. Withegressset, the gateway is the agent's only outbound path, so it can't be bypassed; denied calls feed the same audit/quarantine loop as the broker.verify— refuses to run an image unless it is pinned to an immutable@sha256:digest (a mutable:latestcan change under you between pull and run) and, optionally, pulled only from allow-listed registries. A malformed digest fails closed rather than being treated as unpinned.
Two host-level settings back these up:
- Sealed state at rest (C4) — set
PERCH_MASTER_KEY(or drop a key at.perch/master.key) and.perch/state.jsonis envelope-encrypted as a whole, so a stolen copy or backup yields ciphertext, not credentials. In memory it's always plaintext; only the disk file is sealed. Loading a sealed file with no key fails loudly rather than discarding state. - Authenticated control plane (C7) —
perch serve --require-authgates the console API behind bearer tokens with viewer/admin roles (writes requireadmin).
Crypto backend. The defaults use only the standard library: HMAC-SHA256 for
identities, an HKDF + encrypt-then-MAC scheme for sealed state. Install the optional
extra (pip install -e ".[crypto]") and Perch automatically upgrades to Ed25519
identities (the broker verifies with a public key it cannot forge) and Fernet
for sealed state — no config change. PERCH_CRYPTO_BACKEND=stdlib forces the
stdlib path even when the extra is present. The optional dependency is never
required; stdlib is the baseline.
Pointing an agent at its gateway. The gateway only mediates calls the agent's MCP
client actually sends to it, so point the client at the gateway. perch mcp-config <service> prints a ready-to-paste mcpServers entry for it. A full walkthrough —
identity + egress + mcp together — is in
examples/secure-agent/.
When identity is enabled, every issuance, denial, and attestation result is
written to a tamper-evident audit log (hash chain + keyed anchor); repeated denials
trip an anomaly threshold that can quarantine a subject and revoke its tickets
(C10/C11). Residual gaps for every control are documented honestly in
THREAT_MODEL.md — they are not hidden.
perch serve runs a single-page command center at http://127.0.0.1:8787:
- Topology — the dependency graph: workloads perched on managed services, edges are bindings, node rings show health.
- Services / detail — status, health, config (secrets masked), bindings and exports (masked), and live logs.
- Deploy — the reconcile plan as a color-coded diff, with one-click apply.
- Drift — divergence between the manifest and what's running.
- Backups — per-database dumps, with on-demand backup.
By default the API binds to localhost and is unauthenticated — put it behind the
proxy with auth (or an SSH tunnel) before any remote use. To require credentials,
run perch serve --require-auth: requests to /api/ then need a bearer token
(PERCH_API_TOKENS or .perch/api_tokens.json; a one-time admin token is printed
if none are configured) and writes require the admin role. All responses mask
secret values and managed-service credentials either way.
| Command | Function |
|---|---|
perch doctor |
Validate Docker, Python, and port prerequisites. |
perch up |
Initialize if needed, then reconcile and expose. |
perch init |
Write a starter perch.yaml. |
perch validate |
Check the manifest's structure (no Docker needed). CI-friendly; exits non-zero on problems. |
perch plan |
Show the diff between manifest and what's running. No changes applied. |
perch apply [--rebuild] |
Reconcile: build changed sources, recreate changed configs, restart stopped services. |
perch status |
List services and health. |
perch logs <service> [-f] |
Stream service logs. |
perch drift |
Read-only posture report (non-zero exit on drift). |
perch run <service> |
Execute a one-shot run of a service. |
perch proxy |
Generate the Caddy config and run the reverse proxy (automatic TLS). |
perch scheduler |
Foreground loop: cron-scheduled agents and managed-service backups. |
perch backup [service] |
Dump managed Postgres services (retention applied). |
perch restore <service> <file> |
Restore a Postgres service from a dump. |
perch serve [--require-auth] |
Run the web console + API (optionally token-gated). |
perch destroy |
Remove all managed containers for the project. |
Perch tracks what it owns with container labels and two hashes: a source hash
(build identity, plus a content fingerprint of a local build context, so source
changes rebuild) and a config hash (env, ports, routing, security, schedule —
a change recreates the container; no change is a no-op). The opt-in security fields
are mixed into the config hash only when set, so enabling one is a real change while
existing manifests hash exactly as before. perch drift reports anything missing,
stale, stopped, unhealthy, or running outside the manifest.
Baseline — always on, for every workload:
- Containers run with safe defaults: non-root, read-only root filesystem, all Linux
capabilities dropped,
no-new-privileges, and memory/CPU limits. Relaxations are explicit and show up in change review. - Secrets are referenced from the environment, never committed, and written to a
0600env file at launch so values don't appear in process listings. - Local state (
.perch/state.json) is written0600via an atomic replace. - TLS certificates for routed hostnames are issued and renewed automatically.
Opt-in — per-agent hardening: per-run scoped identities, attestation, controlled egress, tool/MCP mediation, supply-chain pinning, sealed state, and an authenticated console. See Securing agents and THREAT_MODEL.md.
| Word | What it means |
|---|---|
| App / agent | A website/service, or a background helper. |
| Managed service | A ready-made backend block Perch runs for you — a database, storage, a cache, an API, or auth. |
Manifest (perch.yaml) |
The one file where you list everything you want running. |
| Bindings | "This app uses that database" — Perch wires the connection in automatically. |
| Secret | A password or API key. You keep it in your environment; it never goes in the file. |
| Identity | An opt-in, short-lived, least-privilege credential Perch mints per run, instead of a permanent password. |
| Proxy | The piece that gives your site a web address and automatic HTTPS. |
| Drift | A check for anything that no longer matches your file. |
Run this first — it explains most problems in plain words:
perch doctor| What you see | What it means | What to do |
|---|---|---|
| "Docker isn't ready" | Docker isn't running | Open Docker Desktop and wait for it to start |
| "Port 80 is in use" | Something else is on the web port | Close the other program, or skip perch proxy for now |
| "missing secret: …" | A password/key isn't set | Run export NAME=value, then perch up again |
| Page won't load | The port doesn't match your app |
Set port to the number your app shows when it starts |
| "sealed but no key is configured" | .perch/state.json was sealed and the key is missing |
Restore .perch/master.key or set PERCH_MASTER_KEY |
See perch status (what's running), perch logs <name> (output), and
perch destroy (tear it all down). The deeper deployment and production guide is
in GETTING_STARTED.md.
- Docker Engine (or Docker Desktop), installed and running.
- Python 3.10+.
- Ports 80/443 available if you use the built-in proxy.
- Optional: the
cryptoextra (cryptography>=42) for Ed25519 identities and Fernet-sealed state. Never required — stdlib is the default.
The reconciler is backend-agnostic. Docker is the shipped implementation; the
Backend protocol in perch/backend.py can target Compose, Nomad, a remote host
over SSH, or another runtime without changing reconciliation logic.
perch/
manifest.py schema, defaults, secret resolution, hashing, dependency order
backend.py backend interface + observed-state + managed-spec types
docker_backend.py default backend: build/spec + hardened runtime, exec, dumps
catalog.py managed-service catalog (postgres, cache, storage, rest-api, auth)
state.py generated managed-service credentials (.perch/state.json), sealing
backups.py backup layout + retention
api.py HTTP API for the web console (stdlib; secrets masked; token auth)
web/index.html single-page command center
reconcile.py backend-agnostic plan / apply / drift, bindings + ordering
proxy.py Caddy configuration generation
cli.py operator commands + scheduler + serve
# --- secure-by-default agent runtime (opt-in; see THREAT_MODEL.md) ---
identity.py C2 per-agent cryptographic identity (HMAC -> Ed25519)
broker.py C1 short-lived, scoped credential broker
attest.py C3 attestation of config + build identity before issuance
crypto.py C4 sealed state (HKDF encrypt-then-MAC -> Fernet)
dataplane.py C5/C6 identity-aware per-run datastore credentials + scopes
egress.py C8 egress policy + network segmentation
mediation.py C9 MCP / tool-call mediation policy (tools/resources/prompts)
mcp.py C9 MCP protocol decision core (JSON-RPC, per-method mediation)
gateway.py C9 per-agent mediating gateway sidecar (runtime enforcement)
memory.py C10 tamper-evident agent memory log
audit.py C11 audit log, anomaly detection, quarantine
supplychain.py C12 image digest pinning + registry allow-list
install.sh prerequisite-checking installer
THREAT_MODEL.md controls C1-C12, trust boundaries, adversaries, residuals
examples/hello-web/ reference application
examples/secure-agent/ worked agent example + runnable demo assistant (replace with yours)
design/ console design source
tests/ offline suite (no Docker) + e2e_gateway.py / docker_gateway_check.py (integration)
.github/workflows/ CI: offline suite on 3.10-3.12 (both crypto backends) + Docker integration job
Apache-2.0.