Deploying the Pushleft control plane
This is the layout the code supports today, not a target architecture. Every statement here was checked against the code at the time of writing; gaps are marked gap. Operations, SLOs and recovery are in docs/operations.md; runbooks are in docs/runbooks/.
Nothing here has been run on a live Hetzner fleet. The units were written against the binaries’ configuration code and the local test suites, not exercised under systemd on a real host.
Topology
| Host | Units | Notes |
|---|---|---|
| Control-plane host (one) | pushleft-api, pushleft-scheduler, pushleft-agent-gateway, pushleft-controller, pushleft-telemetry-gateway, pushleft-otelcol-gateway, pushleft-ops-alerts.timer, plus a TLS-terminating proxy (for example Caddy) in front of the API | API, scheduler and gateway must share one host and one service user: the source store is a local 0700 directory that the API writes, the gateway reads and the scheduler’s retention deletes (crates/pushleft-source, private_directory). Splitting them needs the object-store adapter (gap). Run one instance of each service; multiple schedulers, controllers or gateways have not been tested. |
| Control-plane PostgreSQL (two, PostgreSQL 17+) | the distribution’s PostgreSQL unit, plus pushleft-postgres-basebackup.timer on the primary | Primary + synchronous streaming replica, WAL archive and PITR from deploy/postgres (below). Failover is manual, no automatic promotion (see DR). Gateway transactions require PostgreSQL 17 or newer (agent-gateway.md). |
| Mesh host | pushleft-mesh, Docker, a Caddy whose admin API is on loopback | Runs one Headscale container per environment and publishes them over HTTPS through its Caddy (mesh-coordinators.md). |
Build pool (pool=build) | pushleft-agent (with --buildkit-image), pushleft-otelcol, Docker | Rootless BuildKit per build (build-executor.md). No tenant runtime or databases. |
Application pool (pool=application) | pushleft-agent, pushleft-otelcol, Docker, host Caddy on 80/443 | This is also the ingress host: the agent writes routes into the Caddy on the node that runs the web instances. The operator’s wildcard *.<base> record must reach that node. With more than one application node there is no shared edge that routes to the right node (gap). |
Database pool (pool=database) | pushleft-agent, pushleft-otelcol, Docker, XFS data volume with project quotas | Managed PostgreSQL/ClickHouse containers (managed-databases.md). |
| External | Vault/OpenBao, OCI registry (Basic auth), ClickHouse for telemetry, Cloudflare, Openprovider, Hetzner | Not deployed by these units. |
Node units (pushleft-agent*.service, pushleft-otelcol.service) and their provisioning are described in fleet.md.
Ports and flows
| Listener | Default in the examples | Who connects | Auth |
|---|---|---|---|
API PUSHLEFT_BIND_ADDR | 127.0.0.1:8080 | Only the local TLS proxy (public 443 → here). CLI and console reach it through the proxy. | Production tokens/sessions; the API refuses requests that do not come from PUSHLEFT_TRUSTED_PROXIES with X-Forwarded-Proto: https (authentication.md) |
Agent gateway PUSHLEFT_AGENT_GATEWAY_BIND | 0.0.0.0:8443, private network only | Agents (outbound from nodes) | mTLS, client certificate must be an unrevoked fleet_node_certificates row |
Enrollment PUSHLEFT_AGENT_ENROLL_BIND | 0.0.0.0:8444, provider network only | New nodes, once | Server TLS + one-time secret; not rate-limited (gap) |
Telemetry gateway PUSHLEFT_TELEMETRY_GATEWAY_BIND | 0.0.0.0:8445, private network only | Node collectors | mTLS, telemetry-purpose certificate |
| Central collector OTLP/HTTP | 127.0.0.1:14318 | Telemetry gateway only | loopback |
Health PUSHLEFT_HEALTH_BIND | 127.0.0.1:9102 gateway, 9103 scheduler, 9104 controller, 9105 mesh, 9106 telemetry gateway | Local monitoring, pushleft-ops-alerts | none; bind to loopback |
| Node Caddy | 80/443 public on application nodes; admin on loopback | Internet | TLS per architecture.md “Ingress publication” |
Outbound from the control-plane host: PostgreSQL (all services), the registry (scheduler retention deletes manifests), Cloudflare and Openprovider (controller, and API for domain quotes), ClickHouse (central collector, analytics sink). The mesh host reaches PostgreSQL, Vault, Cloudflare and its local Docker/Caddy. Nodes only dial out: gateway, telemetry gateway, registry, Vault, Headscale.
Service users and files
Application nodes also need br_netfilter loaded with net.bridge.bridge-nf-call-iptables=1 persisted (/etc/modules-load.d/pushleft.conf, /etc/sysctl.d/90-pushleft.conf); pushleft-agent manage-firewall refuses to run without it. Cloud-init does this on Hetzner nodes.
| Unit | User | Writable paths | Secret files it reads |
|---|---|---|---|
pushleft-api, pushleft-scheduler, pushleft-agent-gateway | pushleft (shared, see Topology) | /var/lib/pushleft/sources (API creates it via StateDirectory; scheduler deletes; gateway read-only) | gateway: server key, agent CA key, registry password; scheduler: registry password |
pushleft-controller | pushleft-controller | none | none (Cloudflare token is an environment value) |
pushleft-telemetry-gateway | pushleft-telemetry | none | server key, analytics hash key |
pushleft-mesh | root | /var/lib/pushleft/headscale, /var/lib/pushleft/mesh | Vault token, Cloudflare token |
pushleft-ops-alerts | pushleft-ops | none | none |
/etc/pushleft/<unit>.env files are root:root 0600; systemd reads them before dropping privileges. Each deploy/systemd/<unit>.env.example lists every variable that binary reads, marks secret values and secret file paths, and gives the default. Secret files go under /etc/pushleft/<unit>/, owned by the service user, mode 0600; the binaries refuse key files with group or other permission bits. Because the three source-store services share a UID, each can read the others’ secret files. That is a consequence of the shared store, not a design goal.
useradd --system --home /nonexistent --shell /usr/sbin/nologin pushleft
useradd --system --home /nonexistent --shell /usr/sbin/nologin pushleft-controller
useradd --system --home /nonexistent --shell /usr/sbin/nologin pushleft-telemetry
useradd --system --home /nonexistent --shell /usr/sbin/nologin pushleft-ops
install -m 0755 target/release/pushleft-{api,scheduler,agent-gateway,controller,mesh,telemetry-gateway} /usr/local/bin/
install -d -m 0755 /usr/local/lib/pushleft/scripts /usr/local/lib/pushleft/deploy/observability/alerts
install -m 0755 scripts/ops-alerts.sh /usr/local/lib/pushleft/scripts/ # needs psql and curl on the host
install -m 0644 deploy/observability/alerts/control-plane.sql /usr/local/lib/pushleft/deploy/observability/alerts/
install -m 0644 deploy/systemd/pushleft-*.service deploy/systemd/pushleft-ops-alerts.timer /etc/systemd/system/
install -m 0600 deploy/systemd/api.env.example /etc/pushleft/api.env # then edit; repeat per unit
systemctl daemon-reload
Database roles
All services connect to one database. Run the SQL below as the database owner after the first migration (the GRANT ... ON ALL TABLES statements only cover tables that exist, so rerun them after every migration that adds tables). scripts/capacity-e2e.sh creates these roles with this exact grant set and runs the API, scheduler, gateway, audit and the alert rules under them. That run exercises deployment creation, placement and leasing. The production-auth, source-upload, build-completion, ingress and teardown paths have not been run under these roles. Their test suites use the owner role.
| Role | Attributes | Used by |
|---|---|---|
pushleft_owner | owns the schema; the only role with DDL | pushleft-api migrate, pushleft-api bootstrap-owner |
pushleft_api | LOGIN NOBYPASSRLS, not owner | pushleft-api service. RLS applies: tenant policies are the second line of defence behind the application predicates. |
pushleft_scheduler | LOGIN BYPASSRLS | pushleft-scheduler (placement spans organizations) |
pushleft_gateway | LOGIN BYPASSRLS, member of pushleft_agent_gateway | pushleft-agent-gateway (startup refuses a role without BYPASSRLS; artifact publication needs the group’s EXECUTE) |
pushleft_controller | LOGIN BYPASSRLS | pushleft-controller daemon; operators run pushleft-controller fleet with it too |
pushleft_telemetry | LOGIN BYPASSRLS | pushleft-telemetry-gateway (startup checks rolbypassrls) |
pushleft_mesh | LOGIN BYPASSRLS | pushleft-mesh run (publish_mesh_credential needs an explicit EXECUTE) |
pushleft_ops | LOGIN BYPASSRLS, read-only by default | pushleft-ops-alerts, pushleft-api audit |
CREATE ROLE pushleft_api LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
CREATE ROLE pushleft_scheduler LOGIN PASSWORD '...' NOSUPERUSER BYPASSRLS;
CREATE ROLE pushleft_gateway LOGIN PASSWORD '...' NOSUPERUSER BYPASSRLS;
CREATE ROLE pushleft_controller LOGIN PASSWORD '...' NOSUPERUSER BYPASSRLS;
CREATE ROLE pushleft_telemetry LOGIN PASSWORD '...' NOSUPERUSER BYPASSRLS;
CREATE ROLE pushleft_mesh LOGIN PASSWORD '...' NOSUPERUSER BYPASSRLS;
CREATE ROLE pushleft_ops LOGIN PASSWORD '...' NOSUPERUSER BYPASSRLS;
GRANT USAGE ON SCHEMA public TO pushleft_api, pushleft_scheduler, pushleft_gateway,
pushleft_controller, pushleft_telemetry, pushleft_mesh, pushleft_ops;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO pushleft_api, pushleft_scheduler,
pushleft_gateway, pushleft_controller, pushleft_telemetry, pushleft_mesh;
REVOKE ALL ON _sqlx_migrations FROM pushleft_api, pushleft_scheduler, pushleft_gateway,
pushleft_controller, pushleft_telemetry, pushleft_mesh;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO pushleft_api, pushleft_scheduler,
pushleft_gateway, pushleft_controller, pushleft_telemetry, pushleft_mesh;
GRANT DELETE ON organization_memberships TO pushleft_api; -- member removal
GRANT DELETE ON network_attachment_requests TO pushleft_scheduler; -- pre-seal reservation reclaim
-- The pre-tenant auth functions; migrations 0027 and 0038 revoke PUBLIC execute.
GRANT EXECUTE ON FUNCTION pushleft_authenticate_token(bytea), pushleft_authenticate_session(bytea, uuid),
pushleft_redeem_login_code(citext, bytea), pushleft_ensure_user(citext),
pushleft_user_memberships(uuid), pushleft_rate_limit_hit(text, text, text, integer, integer),
pushleft_totp_challenge(bytea) TO pushleft_api;
-- Optional: replication details (lag, sync state) in /readyz and the replay_lag alert.
GRANT pg_read_all_stats TO pushleft_api, pushleft_ops;
-- Migration 0024 creates the NOLOGIN group with EXECUTE on publish_release_artifact.
GRANT pushleft_agent_gateway TO pushleft_gateway;
GRANT EXECUTE ON FUNCTION publish_mesh_credential(uuid,uuid,uuid,uuid,bigint,text,text,text,text,timestamptz,text,uuid)
TO pushleft_mesh;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO pushleft_ops;
ALTER ROLE pushleft_ops SET default_transaction_read_only = on;
Every table except _sqlx_migrations has RLS enabled (checked on a migrated database), so the broad table grant to pushleft_api is bounded by tenant policies; several tables have read-only or no tenant policy and so refuse API writes regardless of the grant. A per-table least-privilege grant set for the service roles has not been worked out (gap). The fleet CLI deletes nothing; the controller daemon’s DELETE needs were not audited beyond its test suite, which runs as the owner.
Migrations
Migrations are the embedded sqlx set in crates/pushleft-api/migrations/; no external sqlx-cli is needed.
pushleft-api migrateapplies pending migrations withDATABASE_URLand exits. Use the owner role.pushleft-api(the service) also applies them at startup unlessPUSHLEFT_MIGRATE_ON_START=0. Even with nothing pending, sqlx runsCREATE TABLE IF NOT EXISTS _sqlx_migrations, which needsCREATEon schemapublic. A non-ownerpushleft_apirole therefore fails at startup unless this is0. This was verified on PostgreSQL 17 with a role that had onlyUSAGE.pushleft-api bootstrap-owneralso migrates before it creates the first owner.
Upgrade procedure:
pg_dump --format=custom --file=pushleft-pre-$(date -u +%Y%m%dT%H%M%SZ).dump "$OWNER_URL" # see the restore runbook
systemctl stop pushleft-api pushleft-scheduler pushleft-agent-gateway pushleft-controller pushleft-mesh pushleft-telemetry-gateway
DATABASE_URL="$OWNER_URL" /usr/local/bin/pushleft-api migrate
# rerun the GRANT ... ON ALL TABLES / SEQUENCES statements above if the release added tables
systemctl start pushleft-api pushleft-scheduler pushleft-agent-gateway pushleft-controller pushleft-mesh pushleft-telemetry-gateway
Migrations are forward-only; there are no down migrations. Rollback means restoring the pre-upgrade dump (control-plane PostgreSQL restore). Agents keep running during the window: leases and heartbeats fail, and agents retry until the gateway is back. Their in-flight leases may expire and the jobs retry under the normal fencing. Version skew between binaries and schema is not tested, so stop all services across a migration.
Control-plane PostgreSQL HA
Files: postgres/primary.conf, postgres/replica.conf, postgres/pg_hba.replication.conf, postgres/pushleft-pg-ha.sh, systemd/pushleft-postgres-basebackup.{service,timer}, systemd/postgres-ha.env.example. Install the postgres/ directory as /etc/pushleft/postgres/ (root-owned, 0755, files 0644, the script 0755) on both nodes; archive_command and restore_command call the script there.
| Choice | Setting | Why |
|---|---|---|
| Streaming replication through a slot | wal_level=replica, slot pushleft_replica, max_slot_wal_keep_size=20GB | The replica never misses WAL; a dead replica cannot fill the primary’s disk (past 20 GB it needs a re-base) |
| Synchronous commit | synchronous_commit=on, synchronous_standby_names='ANY 1 (pushleft_replica)' via enable-sync | Losing one node loses no acknowledged write (job leases, fencing generations, revocations). Cost: writes wait while the replica is down until disable-sync. Promotion switches the new primary to async until a replica rejoins |
| WAL archive | archive_mode=on, archive_timeout=60, local /var/lib/pushleft/wal-archive | PITR to any second covered by archive + base backups; the copy is atomic and refuses to overwrite a different file. Off-host copies are the operator’s (gap) |
| Fencing | pushleft-pg-ha.sh fence (standby.signal) or provider power-off/firewall, before promote (which refuses without PUSHLEFT_PG_OLD_PRIMARY_FENCED=yes) | No split-brain writer: a fenced node restarts read-only, and every service’s readiness refuses a standby |
| Rejoin | Re-base with pg_basebackup (bootstrap-replica, PUSHLEFT_PG_REBASE=1) | Always works after divergence; wal_log_hints=on leaves pg_rewind possible (untested) |
Setup, once (as postgres, with /etc/pushleft/postgres-ha.env loaded: set -a; . /etc/pushleft/postgres-ha.env; set +a):
- Primary:
pushleft-pg-ha.sh configure-primary, then restart PostgreSQL (wal_level, archive_mode). It includesprimary.conf, appends the replication hba line forPUSHLEFT_PG_REPLICA_HOSTS, and creates thepushleft_replicatorrole and slot. - Replica, PostgreSQL stopped and the data directory empty:
pushleft-pg-ha.sh bootstrap-replica, then start it. It runspg_basebackupthrough the slot, includesreplica.conf, writesprimary_conninfo(with the password) topostgresql.auto.conf(0600) andstandby.signal. - Primary:
pushleft-pg-ha.sh enable-sync, and checkSELECT sync_state FROM pg_stat_replicationisquorum. - Primary:
systemctl enable --now pushleft-postgres-basebackup.timer. Copy/var/backups/pushleft/postgresand the archive off the host. ops-alerts.env:PUSHLEFT_OPS_REPLICA_DATABASE_URLfor the standby (the read-onlypushleft_opsrole exists there through replication).
The migration-owner role also needs CREATEROLE: migration 0024 creates the pushleft_agent_gateway group role. npm run test:control-plane-ha runs all of this against the pinned PUSHLEFT_POSTGRES_IMAGE on Docker (image layout: config in the data directory). The Debian/Ubuntu layout in postgres-ha.env.example (config in /etc/postgresql/17/main, binaries in /usr/lib/postgresql/17/bin) is written from the package layout and has not been run. Failover and rejoin: runbook; PITR: restore runbook.
Health and shutdown
| Service | Liveness | Readiness |
|---|---|---|
| API | GET /healthz on its listener (204) | GET /readyz on its listener: 200 when the database answers within 2 s and is not in recovery, else 503. The JSON body reports in_recovery, standby replay lag and the primary’s replicas |
| Agent gateway | PUSHLEFT_HEALTH_BIND /healthz (200). The mTLS /healthz needs an agent certificate. | PUSHLEFT_HEALTH_BIND /readyz: SELECT NOT pg_is_in_recovery() true within 2 s |
| Telemetry gateway | PUSHLEFT_HEALTH_BIND /healthz | /readyz: the same database check |
| Scheduler, controller, mesh | PUSHLEFT_HEALTH_BIND /healthz | /readyz: the same database check, and the control loop completed an iteration in the last 30 s |
| Agent | none on the node | the control plane sees fleet_nodes.last_seen_at (alert node_heartbeat_stale); pushleft-agent doctor on the host |
The health listener (crates/pushleft-ops) is plain HTTP with no authentication and no data in the response. Bind it to loopback. It answers only the two exact request lines and 404 for anything else.
SIGTERM, which systemd sends on stop, and SIGINT are handled the same way in every binary:
- API and gateways: stop accepting, finish in-flight requests, then exit. Open SSE event streams end cleanly, and
pl up/pl logsreconnect from their cursor after 0.5 s. If no API answers at that moment the CLI exits with an error, and the deployment carries on server-side. A drain deadline (30 s for the API and agent gateway, 20 s for the telemetry gateway) closes whatever is still open;TimeoutStopSec=45ssends SIGKILL after that. - Scheduler and controller: the current reconciliation tick finishes (each runs in its own transaction), then the process exits. The scheduler’s retention task is dropped mid-pass. Its deletes are designed to be re-run (verified-delete, then record).
- Mesh: the tick loop stops, and in-flight coordinator workers are aborted as before. Their job leases expire and retry under the existing fencing.
- Agent: both lanes are cancelled and in-flight child processes are killed, the same as Ctrl-C (agent-gateway.md).