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:

ServiceRole
apiThe public REST API and the backend for the web UI
exchangeAgent message intake and dispatch - a fleet storm here must not take down the UI
pingA near-free “any work for me?” heartbeat. Answered from Redis; never touches PostgreSQL
workerActivity execution, inventory ingest, report rollups
schedulerProfile 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:

  1. The agent wakes on its exchange interval (default 15 minutes, jittered), or immediately when /ping says there is work.
  2. It POSTs /exchange with 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.
  3. 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.
  4. The agent applies the inbound messages and acknowledges them in its next exchange.
  5. 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 LOCKED queue 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 .deb and a systemd target. The dev environment is the deployment target.
  • No cloud services. Portrait must run fully air-gapped.