How Portrait works
One Ubuntu box runs the whole server. Every managed Windows host runs a small outbound-only agent. The two talk over a single HTTPS message exchange.
The two tiers
The application tier is a single Python package with five roles inside it, fronted by nginx:
| Service | Role |
|---|---|
api | The public REST API and the backend for the web UI |
exchange | Agent message intake and dispatch - a fleet storm here must not take down the UI |
ping | A near-free “any work for me?” heartbeat. Answered from Redis; never touches PostgreSQL |
worker | Activity execution, inventory ingest, report rollups |
scheduler | Profile evaluation, alert evaluation, maintenance. A singleton, leader-elected via a PostgreSQL advisory lock |
The data tier is PostgreSQL 16 (system of record and job
queue, via SELECT … FOR UPDATE SKIP LOCKED),
Redis for ephemeral state, and an object store for script attachments.
A homelab runs both tiers on one 4 GB VM. A larger site splits the
exchange tier off. No code path knows the difference: PostgreSQL, Redis and
the object store are always addressed by URL, TLS to the database works from day one, and
migrations never assume superuser. The practical test is that the app tier runs in a
container with no persistent volume.
No inbound connection, ever
Nothing on the network ever connects to a managed host. This is the load-bearing architectural decision, and everything else bends around it. It is what lets the agent work through NAT, on roaming laptops, and across VLAN boundaries - and it is the property most commercial tools give up.
APPLICATION TIER - Ubuntu 24.04 LTS
+---------------------------------------------------------------+
| nginx :443 TLS · static SPA · rate limiting |
| / -> web SPA (React + TypeScript) |
| /api/v1/* -> api workers |
| /exchange -> exchange workers (agent intake) |
| /ping -> ping worker (Redis only) |
| scheduler · worker x N |
+---------------------------------------------------------------+
| URL, never a socket
v
+---------------------------------------------------------------+
| PostgreSQL 16 - system of record + job queue |
| Redis - ping state, sessions, rate limits, locks |
| Object store - script attachments (file:// or s3://) |
+---------------------------------------------------------------+
^
| HTTPS 443, agent-initiated ONLY
+------------+------------+------------+
Windows host Windows host Windows host
[ portrait-agent ] [ portrait-agent ] [ portrait-agent ]
spool.db · collectors · PowerShell host
The message exchange
The heart of the system, modeled on Landscape’s:
- The agent wakes on its exchange interval (default 15 minutes, jittered), or
immediately when
/pingsays there is work. - It
POSTs/exchangewith its identity assertion, a monotonic sequence number, the last server sequence it acknowledged, and a batch of outbound messages - inventory deltas, activity results, monitoring samples, alerts. - The server persists the batch idempotently, keyed on
(agent_id, sequence), acknowledges it, and returns a batch of inbound messages - activities to run, config changes, profile deltas. - The agent applies the inbound messages and acknowledges them in its next exchange.
- Anything unacknowledged is retried. The agent’s local SQLite spool survives reboots and server outages.
What this buys: works through NAT and roaming; no listening port; naturally batched (one round trip carries everything); at-least-once delivery with idempotent application; and an offline agent is a lagging agent, not a lost one.
/ping exists purely so the 15-minute interval doesn’t make
interactive actions feel dead. The agent sends its ID; Redis answers with a boolean.
Default every 30 seconds. An admin clicking “run script” wants it to
start in seconds, not minutes.
Everything is an activity
Every mutation - an install, a reboot, a script run, a profile-driven change - is an activity: queued, attributable to whoever (or whatever) created it, cancellable while pending, and observable to completion. A batch dispatched to many hosts is an activity group whose parent aggregates its children’s results, and each child is addressable by its parent. Nothing is fire-and-forget.
Data flow: an admin runs a script
Admin -> POST /api/v1/scripts/42/run {query: "tag:workstation"}
-> RBAC check (access group ∩ role permissions)
-> resolve query -> 37 agents
-> create ActivityGroup + 37 child Activities (queued)
-> 201 with activity_group_id; Redis: mark 37 agents "has work"
Agent -> GET /ping -> true
-> POST /exchange -> receives its child activity
-> executes with timeout + capped output, as configured
-> next exchange posts stdout/stderr/exit code
-> status: succeeded | failed | timed-out
Admin -> GET /api/v1/activity-groups/{id}
-> aggregated status + per-host children (paginated)
Software sources - deliberately not ours
Portrait has no repository service. Managed hosts draw content from
wherever they already do: Microsoft Update, the public WinGet source, or an internal
source you already run. What Portrait supplies is governance over that content
- which classifications apply to which hosts, when; maintenance windows and reboot
orchestration for the aftermath; package profiles that enforce presence and absence.
Line-of-business apps install through remote script execution
(msiexec /i \\share\app.msi /qn), which is how small shops already do it.
Deliberate non-dependencies
- No RabbitMQ. A PostgreSQL
SKIP LOCKEDqueue is good well beyond the target scale, and it is one less service to run and back up. - No Elasticsearch. PostgreSQL full-text and JSONB indexes cover inventory search.
- No Kubernetes, and no containers in development. A
.deband a systemd target. The dev environment is the deployment target. - No cloud services. Portrait must run fully air-gapped.