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. There are exactly two optional outbound connections, and the system runs without either:

The application is API-first: the web front end consumes the same public REST (Representational State Transfer, the conventional style for HTTP web APIs) interface that third-party integrations use. 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 running system is the same set of services. 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. 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
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.

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 scoped to the resolved organization, by email and organization.
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, organizationId } 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 only connections that leave the cluster are the two optional outbound ones (the SMTP relay and webhook or notification endpoints), and both originate from the background worker.

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