Technical architecture

Vaks PM · Technical architecture · June 2026

Purpose of this guide. This document describes the architecture of Vaks PM: what the system is made of, how its parts fit together, and the principles that shape it. It is written for a system administrator who is new to the product, and stays at a conceptual level. It explains how the pieces relate, not the exact commands to install them. For the step-by-step installation procedures, see the deployment guides linked at the foot of the page. Every acronym is defined at first use and collected in the glossary below.

Overview

Vaks PM is a multi-user project-management web application built to run entirely on infrastructure the organization controls. It is on-premise, that is, it sits on the organization's own servers rather than on a third-party online service. The product ships as a small set of container images, and a complete installation has no mandatory dependency on any external online service.

The whole product reduces to a handful of container images plus three durable backing stores (a relational database, a cache, and an object store). Anything else the system needs, such as load balancing, failover coordination and real-time messaging, comes from components the installation hosts itself. Every outbound connection is optional, and the system runs without any of them:

The application is API-first: the web front end and third-party integrations both consume the same REST (Representational State Transfer, the conventional style for HTTP web APIs) API surface, but third-party callers (API tokens) are restricted to routes explicitly marked as public, while the browser session can reach the full application surface. Authorization is enforced on the server for every request and every real-time event, and the front end is never trusted to police access on its own. The services that carry application logic, the API and the background worker, are stateless (they hold no local data) and so scale horizontally by simply running more copies.

Glossary

Acronyms used throughout this guide, defined once here for reference.

TermMeaning
On-premiseSoftware hosted on infrastructure the organization controls, rather than on a vendor-operated online service.
HAHigh Availability: a topology that survives the loss of a single host without an outage.
DNSDomain Name System, which maps host names to network addresses.
TLSTransport Layer Security, the encryption layer behind HTTPS.
VIPVirtual IP: a single network address shared by several hosts so it can fail over between them.
S3Simple Storage Service, an object-storage interface that originated at Amazon and is now an industry de-facto standard. Vaks PM stores files in an S3-compatible store it hosts itself.
JWTJSON Web Token, a signed session token issued after a successful sign-in. Vaks PM stamps it with the tenant identity.
WS / WebSocketA persistent, two-way browser-to-server connection used for real-time updates, as opposed to one-shot request/response calls.
ORMObject-Relational Mapping, a library that maps database tables to typed code objects and manages schema changes.
DCSDistributed Configuration Store: a small, highly available key-value store that holds cluster state and elects the database leader.
TenantOne isolated organization served by the instance. Each tenant has its own database.
SMTPSimple Mail Transfer Protocol, the protocol for sending email; used for notifications.
DCDatacenter, a physical site hosting one cluster.

Technology stack

The system is TypeScript end to end: the same typed language spans the browser front end, the back end and the shared data contracts between them, so a single type definition can guarantee that client and server agree on every payload. The table below lists each layer and the technology that fills it.

LayerTechnologyRole
Front endReact + TypeScript, built with Vite; styled with TailwindThe single-page web application served to the browser as static assets.
Back endNestJS (Node.js / TypeScript)The REST API and the real-time WebSocket channel. Stateless and horizontally scalable.
ORMPrismaMaps the database to typed code and applies schema changes at start-up.
DatabasePostgreSQL 17The system of record. One database per tenant. Run in HA with an automatic-failover manager.
Cache / queueRedis 7Cache, publish/subscribe messaging, and the background job queue.
Object storeS3-compatible: Garage on Swarm, MinIO on KubernetesStores binary files: attachments, branding assets, document templates, audit archives.
Reverse proxyTraefik on Swarm; the cluster Ingress controller on KubernetesTerminates TLS and routes incoming requests by host name.
OrchestrationDocker Swarm (reference production) or KubernetesSchedules the containers and supports rolling, zero-downtime updates.
The choice between Docker Swarm and Kubernetes does not change the application: both run the identical images and support the same on-premise object storage. The decision is driven by the operations team's existing tooling, not by any feature difference. See Docker Swarm deployment and Kubernetes deployment.

What runs

Whichever orchestrator is used, the core application services are the same; the MCP / AI connector is currently Swarm-only. Application logic is kept stateless wherever possible: the api, web and worker services hold no local data and can be scaled simply by running more replicas. All durable state lives in the three backing stores (PostgreSQL, Redis and the object store).

ServiceRoleStateful?
webServes the React single-page front end (static assets).No
apiNestJS back end: the REST API (prefix /api/v1) plus the real-time WebSocket channel.No
workerBackground job runner: email sending, webhook delivery, audit forwarding, scheduled tasks, Microsoft Graph calls for Teams project provisioning. Consumes the job queue on Redis.No
controlplaneThe shared front door in multi-tenant mode: authenticates each request and routes it to the correct tenant. The same image as api, run with a different role flag.No
mcpMCP (Model Context Protocol) / AI connector: a Streamable-HTTP server AI clients (Claude, Copilot Studio, ChatGPT…) talk to. It relays the caller's bearer token to api for every call and holds no secrets and no database connection of its own. Swarm only — not currently packaged for the Kubernetes Helm chart.No
PostgreSQL 17The primary relational database. One database per tenant.Yes
Redis 7Cache, publish/subscribe, and the background job queue.Yes
Object store (S3)File attachments, branding assets, document templates, audit archives.Yes
Reverse proxyTerminates TLS and routes by host name. Traefik on Swarm; the cluster Ingress controller on Kubernetes.No

Stateless tier scales out

Because api, web and worker keep nothing locally, several replicas can run side by side. They absorb load and let a rolling update proceed without downtime.

State is concentrated

All durable data sits in exactly three stores. This keeps the backup surface small and well-defined: a database, a cache and an object store.

One image, two roles

The controlplane service is the same build as api with a role flag flipped, so there is no separate codebase to maintain for the shared front door.

Control plane and data plane

The architecture separates two responsibilities into two logical planes:

A single container image serves either role through a configuration flag; there is no separate product for the front door. In a single-organization on-premise install this distinction is essentially invisible, and it becomes meaningful only when one instance serves several organizations. The benefit of the split is containment. A flaw in one tenant's data access (a missed WHERE clause, an SQL injection, or an attempt to reach another user's object by its identifier) is confined to that single tenant's database, because the database role for one tenant cannot read another's.

Browsers one host name per organization 443 · 80 CONTROL PLANE · SHARED Reverse proxy TLS · route by host name Shared authentication issues JWT { tenant } JWT { tenant } DATA PLANE · ISOLATED PER ORGANIZATION Organization A api · worker database db_orgA Organization B api · worker database db_orgB Organization C api · worker database db_orgC
Logical architecture: a shared control plane authenticates and routes by host name, while each organization's data is isolated in its own database. Only the public HTTPS port is exposed to clients.

Tenancy models

A tenant is one isolated organization served by the instance. A single configuration flag sets how many tenants an instance carries and how their data is isolated. There are two modes:

ModeMeaningTypical use
dedicatedOne organization per instance. The host name is not used to pick a tenant, since there is only one. This is the standard on-premise case.A single company running its own private installation.
pooledSeveral organizations on one instance. Each is resolved from its host name and isolated in its own database, named <db>_<slug> (a base database name followed by the organization's short identifier).An operator hosting many organizations on shared infrastructure.

In both modes the rule is the same: one database per tenant, each with its own database role. The several databases can live inside a single highly available PostgreSQL cluster, which is shared infrastructure over separated data. The application never hard-codes which database to use; it obtains a connection through a tenant context. In dedicated mode that context always returns the single database; in pooled mode it returns <db>_<slug> for the resolved tenant.

The cells variant

The pooled model can be deployed in a stricter form called cells: instead of one shared application tier serving every tenant, each tenant gets its own application instance and its own database, sitting behind the same shared control plane. This trades some efficiency for maximum isolation, since a tenant's compute is no longer shared with anyone else's. The same image still runs everywhere; only the deployment shape differs. One useful property of this model is that a non-production tenant (for example a demo or test organization) is just another cell and is fully disposable: its database can be dropped and reseeded without touching any other tenant.

user @ acme user @ globex user @ demo CONTROL PLANE · shared (login + routing) Shared auth + tenant directory → JWT { tenant } Reverse proxy · TLS *.example.com route by Host → tenant cell acme → globex → demo → DATA PLANE · isolated per tenant ACME cell api-acme (app) db-acme GLOBEX cell api-globex (app) db-globex DEMO cell disposable · drop + reseed api-demo (app) db-demo db-acme · db-globex · db-demo = N databases + separate roles in 1 PostgreSQL HA cluster (shared infrastructure) Pooled: 1 shared app serves all tenants → picks db-<tenant> · Cells: 1 app + 1 db per tenant (max isolation) same image everywhere · only switch: tenancy mode = dedicated | pooled
The cells variant of multi-tenancy: each tenant has its own application instance and database behind a shared control plane. Non-production tenants (such as a demo) are disposable cells that can be dropped and reseeded in isolation.

Hardware requirements

Sizing follows two principles inherited from the architecture. First, the stateless application tier (web, api, controlplane, worker) is light and roughly flat: it consumes little and absorbs load by adding replicas. What actually grows is the stateful data tier, of which PostgreSQL is by far the largest memory consumer. Second, the tenancy mode decides how that data tier grows: in dedicated it serves a single organization, whereas in pooled each tenant adds its own database, its own connection pool and its own share of working memory — so the data tier grows per tenant.

These are starting points, not absolute minimums. The figures below are planning recommendations for a typical load (up to ~200 active users per organization). Real consumption depends on data volume, attachment sizes, project count and reporting intensity. Measure under real load and adjust; every value is configurable.

Container footprint (reference)

The reference stack sets per-service memory ceilings, sized to fit on modest hosts. These are ceilings (upper limits), not reservations: in high availability the replicas spread across hosts, so the reference development cluster fits on hosts of roughly 2 GB of RAM each.

ServiceMemory ceiling / replicaReplicas (ref.)Stateful?
web96 MB2No
api640 MB2No
controlplane448 MB2No
worker320 MB2No
mcp160 MB2No
Redis 7no fixed ceiling (small)1Yes
Object store (S3)no fixed ceiling1+Yes
PostgreSQL 17 (+ HAProxy, etcd in HA)largest RAM consumer; grows with data and, in pooled, with tenant count1 (single) / 3 (HA)Yes

The sum of the ceilings across the whole application tier (two replicas of each) is around 3 GB; spread over three hosts, that leaves each host comfortable even on 2 GB. Redis and the object store are deliberately left without a ceiling: an out-of-memory (OOM) kill there would cost data.

Sizing matrix

The four combinations of deployment mode (single-host versus high availability) and tenancy mode (dedicated versus pooled). "Eval." targets an evaluation or a small team; "Recommended" targets production at ~200 active users per organization.

Deployment × tenancyHostsPer host — Eval.Per host — RecommendedNotes
Single · dedicated 1 2 vCPU · 4 GB · 40 GB SSD 4 vCPU · 8 GB · ~40 GB SSD + attachments The standard on-premise case: one organization, one machine. Tolerates brief downtime for maintenance (no redundancy).
HA · dedicated 3 (odd number) 2 vCPU · 4 GB · 40 GB SSD 4 vCPU · 8 GB · ~60 GB SSD + attachments (per host) One organization, no single point of failure. The odd number satisfies the etcd quorum. Survives the loss of one host. Since the object store is replicated, each host holds a copy of the files.
Single · pooled 1 4 vCPU · 8 GB · ~60 GB SSD + attachments + ~0.5–1 GB RAM and each tenant's database disk per active tenant Small operator or demo host serving several organizations. The data tier grows per tenant.
HA · pooled 3+ (odd) Start from the "HA · dedicated recommended" row, then grow the PostgreSQL tier (RAM and disk) as tenants accumulate. The SaaS operator case. The application tier stays flat; it is the database tier that must grow.

Compute is flat, data grows

Adding users is mostly handled by adding cheap stateless replicas. The real RAM driver is PostgreSQL, and in pooled it grows with the number of tenant databases.

Disk is dominated by files

The database + system footprint stays modest — on the order of a few tens of GB for one organization, even over time (relational data, audit purge and archiving). The real disk consumer is the S3 object store (attachments, document templates, audit archives): size it to your expected file volume and watch its growth.

SSD for the database

PostgreSQL syncs writes to disk; low-latency SSD storage is strongly recommended for the database volumes, on both single-host and HA.

Login & routing

The reverse proxy routes incoming traffic by the Host HTTP header, the domain name the browser asked for. The crucial property is that the tenant is resolved before authentication: the proxy hands the Host to the back end, which derives which organization the request belongs to, and only then does it attempt to find the user. This ordering keeps one organization's users from being matched against another organization's accounts.

The behaviour differs between the two tenancy modes, but the infrastructure (the proxy, its routers and the TLS certificates) is identical in both. A single configuration flag flips the behaviour; in dedicated mode the host-based routing simply stays inert.

Stepdedicated (single organization)pooled (several organizations)
Reverse proxy routingRoutes by request path; the Host serves only TLS.Routes by Host (the subdomain) and by path as a fallback.
Tenant resolutionThe Host is ignored; there is only one organization, so resolution returns nothing.The Host is mapped to an organization (by its short identifier or its primary domain). An unknown host is rejected.
User lookupA global lookup by email address (one organization, so no ambiguity).A lookup by email only — but the connection has already been bound to the resolved tenant's own database by the Host-resolution middleware, so the query structurally cannot see another tenant's users (no organizationId filter is needed).
ResultA JWT stamped with the organization, returned with HTTP 200.A JWT stamped with the resolved organization, returned with HTTP 200; an unknown host yields HTTP 401 (unauthorized).
dedicated MODE · on-premise (1 org) pooled MODE · multi-tenant (N orgs) Browser Host: pm.company.com Host REVERSE PROXY router: PathPrefix(/api) route by PATH · Host = TLS only API · login resolve tenant from Host mode=dedicated → none (the Host is ignored) login (tenant = none) find user by { email } JWT { orgId } → 200 OK resolved by email (1 org in production) Browser · 2 tenants acme… / globex.example.com Host REVERSE PROXY · TLS *.example.com Host(<sub>.example.com) → route by HOST + fallback PathPrefix(/api) API · login resolve tenant from Host → org acme→org acme · globex→org globex unknown Host / apex → 401 login (tenant = org.id) find user by { email } — on tenant's DB conn. JWT { orgId } → 200 OK acme→orgId acme · globex→orgId globex PostgreSQL HA (via load balancer) users users
Login flow in the two tenancy modes. In dedicated mode the proxy routes by path and the back end ignores the host; login is a global lookup by email. In pooled mode the proxy also routes by host, the back end resolves the organization first, and login is scoped to that organization, so an unknown host returns HTTP 401.

Real-time channel

Beyond ordinary request/response calls, the application maintains a persistent WebSocket connection (a two-way, always-open browser-to-server channel) so that changes such as a moved task, a new comment or a status update appear live without the page being reloaded. The transport is Socket.IO, a widely used WebSocket library that adds reconnection and fallback handling.

Network & ports

The exposure surface is deliberately small. Only ports 80 and 443 need to be reachable from clients; the reverse proxy redirects plain HTTP (80) to HTTPS (443) and terminates TLS there. Every other service, including the database, the cache, the object store and the application tier, communicates on the cluster's internal overlay network and is never published to the outside. The connections that leave the cluster are all optional: the SMTP relay, webhook/notification endpoints and the SIEM syslog forwarder originate from the background worker; calls to a corporate identity provider or collaboration platform (Entra ID, Slack, Google Workspace) for single sign-on or a collaboration/file-source integration originate from the api — with one exception: Microsoft Teams project provisioning runs as a background job and calls Microsoft Graph directly from the worker, which fetches a short-lived app token from the api rather than holding the client secret itself.

Clients browser · API consumer 443 · 80 Traefik reverse proxy · TLS 3000 web · api · worker stateless · scaled horizontally 6379 5000 3900 Redis 7 cache · queue (BullMQ) HAProxy PostgreSQL load balancer PostgreSQL 17 Patroni HA cluster etcd leader election (DCS) Garage (S3) object storage 5432 2379 · 2380 OUTBOUND FROM WORKER · OPTIONAL SMTP relay 587 · 465 (TLS) Webhook / Teams endpoints 443 (HTTPS)
Network flow and default service ports (high-availability topology). Only ports 443 and 80 are exposed to clients; every other port stays on the internal overlay network. On Kubernetes the same flows map to Services and the Ingress controller. Port numbers are defaults and can be changed by configuration.

In the HA topology shown above, two helpers sit between the application and PostgreSQL: HAProxy, a load balancer that always points to the current writable database node, and etcd, a DCS (distributed configuration store) that holds cluster state and elects the database leader. The application never connects to a PostgreSQL node directly. It goes through the load balancer, so a failover is transparent to it.

Two-datacenter model

Prepared, not validated. The two-datacenter model below is an edge case. The tooling can generate the configuration for it, but it has not been validated end to end against two real clusters, and the network values (inter-site latency, interconnection, geographic DNS) must be adapted per site. A standard deployment does not need any of this; see Docker Swarm deployment.

For disaster resilience, an instance can be spread across two datacenters (DC) in an active / passive arrangement: one DC serves all traffic, the other stays ready to take over. The guiding constraints are:

GeoDNS / global LB │ ┌─────────┴─────────┐ ▼ ▼ ┌── DC1 (primary) ──┐ ┌── DC2 (standby) ──┐ │ Swarm A │ │ Swarm B │ │ controlplane+api │ │ controlplane+api │ (warm) │ Postgres R/W │→→│ Postgres standby │ async │ Garage (zone dc1) │↔↔│ Garage (zone dc2) │ S3 replication └───────────────────┘ └───────────────────┘

Because replication is asynchronous, a failover (promoting the standby) carries a non-zero RPO (Recovery Point Objective, the maximum tolerated data loss): the few seconds of writes not yet replicated may be lost. The stateless application tier, by contrast, runs identically in both DCs and poses no such constraint. Two genuinely active sites serving writes at once are out of scope, because PostgreSQL is single-writer; that would require geo-distributing the tenants so each one is primary in a single DC.


Related: The product · Docker Swarm deployment · Kubernetes deployment · Security · Operations · Demo