Four roles, one codebase
An investment-pool platform. Investors, traders, accountants and admins read the same numbers and are allowed to do very different things with them — and the origin server has no inbound ports at all.
- Surface
- Investor, trader, accountant and admin, in one installable app
- Work
- Front-end architecture, deployment topology, delivery pipeline
- Stack
- Angular PWA, Caddy, Docker Compose, Cloudflare Tunnel, NestJS, Postgres
Domains, not layers
The obvious way to organise a four-audience app is by technical layer — all the services here, all the components there — and it works right up until a change to what a trader may see has to be traced through six folders that each know a little about everyone.
So the app is cut by domain instead. Admin, investor, trader and accountant each own their features, services, stores and routes. Domains do not import from each other; anything two of them genuinely share is promoted deliberately into shared or core, which makes promotion a decision somebody makes rather than an accident of an import statement. The question "who can see this number" has one place to look.
Nothing dials in
The part worth showing is not the front end. A finance product on a small VPS is a standing invitation: the moment ports 80 and 443 are open to the world, the origin's address is public and the box spends its life being scanned.
It is not open. A tunnel connector inside the network dials outward to the edge and holds the connection open, so traffic arrives through a channel the origin established. The firewall allows SSH and nothing else. Ports 80 and 443 are closed and the origin's address never appears in DNS — the public records point at the tunnel, not at the machine.
Same origin, on purpose
The browser never addresses the API host. The app calls a path on its own origin, and the reverse proxy inside the network decides whether that path is the API or the app shell. There is no second origin to configure CORS for, no preflight on every call, and no API hostname sitting in a bundle for anyone to point a script at.
Deploys that leave nothing behind
A push to the main branch builds the image and pushes it to the registry tagged with the commit. The deploy step then connects to the server, hands it the job's own short-lived token to authenticate the pull, updates the one container and logs out again.
The token expires with the job. There is no long-lived registry credential living on the machine for someone who gets a shell to find later, which is the sort of thing that sits unnoticed on a box for years.
