Anatomy of a €10/Month Company Stack: Ansible, Vault, Postgres and 15 More Services
Why we run our own stack
Our SaaS bill sat between €200 and €400 a month. The number was not the only problem. Seats multiplied with every hire, tiers jumped when we needed one more feature, and the data we cared about was never quite ours to take. We rebuilt the company stack ourselves: 18 services on two small servers, with a third for backups, for about €10 a month in total.
This article is the engineering side of that rebuild. It covers the design rules, the network layout, how secrets reach the processes that need them, how data is isolated, how a deploy and a rollback work, what the backups actually do, and the parts that are still unfinished. It is written for engineers who might run something similar, not as a tutorial. Everything here comes from our own repository and our own bill in November 2026.
The honest summary first: this is a stack for a small company, not a high-availability platform. The main server is a single point of failure for the applications, Postgres is self-managed, and the full disaster-recovery drill is still on our list. The interesting part is how much of the usual operational discipline still fits into about €10 a month of hosting.
Design principles
Four rules shaped most of the repository. They are deliberately boring, and that is the point.
One service, one container. Every service has its
own compose project, its own image tag and its own directory of
persistent data. Nothing shares a container “to save resources”. The CRM
is two containers (crm and crm-worker),
FeatBit is three, Matrix is two plus a systemd timer, but each is
independently restartable and deployable. A failure in one does not
corrupt the others by accident.
One command per service. Deploying is
scripts/deploy.sh <service>. Deploying everything
except Vault is one playbook, playbooks/apps.yml, which
runs the services in dependency order. A guided
scripts/bootstrap.sh takes an empty server from nothing to
a running stack, and it can resume at any step. If an operation cannot
be expressed as a service-level command, it probably should not
exist.
Secrets in memory, never in files that persist.
There is no .env file in the repository, in a compose file,
or in the deployment directory. Containers receive only the paths of
secret files. Vault Agent renders each service’s secrets into a tmpfs
directory, and the container mounts only its own directory, read-only.
This is the one part of the stack where we refuse shortcuts.
Least privilege, enforced by the platform rather than by documentation. Every service has its own Vault document, policy and AppRole. Every service has its own Postgres role and database. A container that is not attached to the internal Docker network cannot reach Postgres at all, whatever password it has. The rules are in the infrastructure, not in a wiki page someone forgets to update.
There is a fifth principle that shows up later: the repository is written so that another person, or an AI agent, can rebuild the stack from the README. That constraint forces explicit order, exact versions and no undocumented manual steps.
Three servers, four zones
There are three servers. The main one runs the applications and their databases. Vault runs on a second one, and a third stores backups. The Ansible inventory is service-per-host, so moving a service to its own machine is a one-line change in hosts.yml; Vault was the first to move. That is deliberate: the plan is to split the stack before growing it.
The network is divided into four zones.
The public edge. Caddy listens on 443 and obtains a Let’s Encrypt certificate for every site, renewing it automatically 30 days before it expires. Port 80 answers only to redirect visitors to HTTPS and to answer the Let’s Encrypt challenge. Caddy then forwards to services that listen on loopback ports: the CRM, the ticket tracker, the wiki, the chat, the password manager, the feature-flag UI, n8n. Vault’s web UI and the wg-easy admin page are also behind Caddy, but only for allow-listed IP ranges.
The internal network. Postgres and Redis live only
on the Docker network gpo-internal. That network has no
published ports and no route to the internet. A service that needs the
database joins the network; a service that does not, cannot see it.
There are no database ports open on the host.
The admin path. WireGuard runs on 51820/udp through wg-easy, and SSH is key-only. The open ports on the host are 22, 80, 443 and 51820/udp. Everything else, including all database and cache ports, is loopback or internal only.
The secret path. Vault runs on its own server and does not answer the open internet. Its API and UI are reachable only from allow-listed addresses, and each service’s Vault Agent runs on the same host as the service it serves. Secrets travel from Vault to a tmpfs file in memory, never to a file on disk.
The site subdomains under gpo-tech.com are
crm., tickets., wiki.,
chat., bitwarden., flags.,
n8n-crm. and vault-crm.. The last one answers
only the addresses on its allow-list, not the open internet. The same is
true of the wg-easy page.
Secrets: what decides whether this works
The secret flow is the part we would keep if we had to throw away everything else.
Ansible generates the random secrets once and writes them to Vault.
Each service has its own Vault document under
secret/gpo-crm/<service>, its own read-only policy
and its own AppRole. The AppRole is allowed to log in only from the
hosts of that service’s group, so n8n’s role cannot read Postgres’s
secrets, and a service moved to another server keeps its secrets and its
name.
One Vault Agent runs per service. It logs in with its AppRole and
renders that service’s secrets into
/run/gpo-crm-secrets/<service>. That path is a tmpfs
mount, meaning the files live in memory and disappear on reboot. Each
container mounts only its own directory, read-only. If a secret file is
missing, for example after a reboot while Vault is still sealed, the
service fails to start and retries. It does not start with a random key
and quietly diverge from the rest of the system. Failing closed is a
feature.
Most services read their secrets from files: Postgres and Redis read
password files, the S3 clients read their keys, and n8n reads its
encryption key. The CRM accepts its settings only as environment
variables, and Kaneo two of them. For those, a small wrapper inside the
container builds the variables from the secret files at start time. The
values exist only in the processes of that container. They are not in
the compose file, and they do not appear in
docker inspect.
Why ban .env files? Because they fail in a predictable
order. One file gets copied into a second service “temporarily”. The
same values are duplicated between development and production. A compose
file with an environment block ends up in a repository, and environment
variables are visible to anyone who can inspect the container. Rotation
becomes “find every copy and redeploy everything”. A single Vault
document per service plus a read-only memory mount keeps the secret’s
blast radius equal to the service that needs it.
The data layer: one role, one database, one secret each
Postgres is the relational database for the services that need one. Each of them gets its own role, its own database and its own Vault document. The superuser is for people, not applications: it is used by an administrator on the server or over the VPN, and no service connects as it. A compromise of one service exposes its database and no other one.
Redis sits next to it on gpo-internal and is used
by the CRM for cache and queues. Redis data is not backed up, because queue and
cache content is reconstructible.
Two more honest details. n8n still runs on its own SQLite database
until we connect it to Postgres. And services that talk over
gpo-internal have to run on the same server; the network
exists on each host separately. Both are consequences of the current stage and both are documented rather than hidden.
The 18 services
The count is 18: 12 services people use and 6 services that keep the platform running. The split matters, because the operational six are the difference between “I installed some containers” and something you can leave alone for a week.
The twelve services people use
| Service | Role |
|---|---|
| HashiCorp Vault | secrets for every service, delivered to processes in memory |
| PostgreSQL | one database and role per service |
| Redis | cache and queue for the CRM |
| n8n | automation and internal workflows |
| CRM (Twenty fork) | contacts, companies, deals, notes, meetings |
| Kaneo | task tracking |
| RustFS | S3-compatible object storage |
| Wiki.js | internal wiki |
| Matrix | team chat, Synapse plus Element |
| Vaultwarden | passwords, used with the official Bitwarden apps |
| FeatBit | feature flags |
| wg-easy | WireGuard VPN |
The six services that keep it running
| Service | Role | systemd unit |
|---|---|---|
| Emergency notifications | Telegram alerts when Vault is sealed or the chat is down | systemd timer gpo-emergency |
| Weekly cleanup | unused images, apt cache, old scan data | systemd timer gpo-cleanup |
| Notifications in the chat | service alerts and daily reports into Matrix | timers gpo-notify-watch,
gpo-notify-report |
| Security scan | weekly vulnerability scan | systemd timer gpo-trivy |
| fail2ban and honeypot | host and web protection, ban on first touch | systemd fail2ban, gpo-honeypot |
| Backup | encrypted snapshots with restic | systemd timer gpo-backup |
Caddy is the reverse proxy in front of the public services; it is not a row of its own. The CRM is our AGPLv3 fork of Twenty. Custom fields, objects and pipelines are a commit rather than a feature request, but the larger product items on the roadmap, including an audit log, invoicing, IMAP sync and the e-Äriregister integration, are not shipped yet. PostHog now runs on a self-hosted instance; it is not counted among the 18 services in this table, which follow the Ansible-managed service set.
The functional spread is wider than the list suggests. The CRM holds the customer data: contacts, companies, deals, notes and meetings. Kaneo replaced Jira, Wiki.js replaced Confluence, Matrix plus Element replaced Slack, and Vaultwarden replaced 1Password. The ticket tracker, wiki, chat, password manager and feature flags keep their data in Postgres or the S3-compatible bucket; the VPN keeps its keys on the server. The point is not that every tool is the best in its category. The point is that the categories are covered by services we can inspect, version, back up and change.
Deploy and rollback
The repository is one Ansible project. hosts.yml says
which server runs which service group. config.yml holds the
settings a human edits: domain, allow-listed addresses, timezone, image
versions, backup targets. Everything else is a role.
scripts/deploy.sh <service> deploys one service.
Run without an argument, it deploys everything. The scripts ask for the
Vault token instead of reading an exported VAULT_TOKEN,
check that the token is accepted, and never echo it. The token is needed
only to issue or rotate the Agents’ credentials. The CRM’s first
deployment asks for the read-only Docker Hub token for its private
image, and the deployment stops until the mailbox password placeholder
in Vault is replaced with the real one. Small gates like that are why a
deploy does not half-succeed with a broken mailer.
Image versions are pinned in configuration, not latest. Upgrades go
through
scripts/update-image.sh <service> <image>. It
offers a backup first, because an update can migrate data and a
migration cannot be undone. It then edits the pin, deploys the service
and prints the command that rolls the tag back. The last sentence is the
important one: rolling the tag back does not roll the data back. If an
update migrated the database, the rollback path is the backup, not the
previous image.
Postgres major upgrades are refused by the script. Postgres cannot open data files written by another major version, so a move between major versions is a dump, a new empty volume and a restore. We treat that as a planned operation with a backup taken immediately before it, not as a deploy.
The whole stack took one week to build and one day to deploy. That timeline is not a boast about speed; it is a statement about how much of the work is configuration and documentation rather than custom code.
Backups and disaster recovery
Backups run daily from a systemd timer and write an encrypted, deduplicated restic snapshot. Retention is 7 daily, 4 weekly and 12 monthly snapshots. The primary target is the server’s own disk, which protects against mistakes. The copy that protects against losing the server sits on a separate rented server that runs our own MinIO, an S3-compatible store we use only for backups. Each target is a separate repository with the same data, so a failing target does not stop the others; the run is still reported as failed.
What goes into a snapshot depends on the service. Postgres contributes a dump of every database and role. Vault contributes a snapshot of its data. n8n contributes its data directory, the CRM its uploaded files, RustFS its objects, the wiki its cache, Matrix its signing key, wg-easy its WireGuard keys. Redis contributes nothing, on purpose.
Everything is encrypted before it leaves the server, so the backup server holds only ciphertext. The restic password and the MinIO access keys never touch the server’s disk; Vault Agent renders them into memory like every other secret. A copy of the restic password lives in our password manager. Without it, losing Vault would mean the snapshots cannot be opened. That is not a theoretical detail; it is the one secret that must exist outside Vault.
We set an RPO of 24 hours or less, because the backup runs daily, and an RTO target of 4 hours. The RTO is a target, not a measurement, and we say so because the next paragraph is the important one.
Weekly verification is automated. Every Sunday a job reads back 20%
of the data, and when the latest snapshot contains the Postgres dump it
restores that dump into a throwaway Postgres container with no network
and counts the databases and tables. The result goes to the chat’s
#infra-reports room; a failure goes to
#infra-alerts. That catches the common failure where a
backup job runs but produces something unreadable.
What is not done: a full restore drill on a temporary server. We have not yet rebuilt the stack from backups on a clean machine and measured the real RTO. Until that drill happens, “4 hours” is the plan, not a proven number. We would rather write these two sentences than imply a resilience we have not tested.
Hardening
The security posture is deliberately conventional. None of it is clever, and most of it is standard advice that people skip.
SSH is key-only: password login is disabled. ufw allows
only the ports the services need. fail2ban runs jails for SSH, the web
sites, Vaultwarden and FeatBit, and a honeypot bans scanners on first
touch. Trivy scans the images weekly and reports. Caddy handles TLS with one Let’s Encrypt certificate per site
and renews it automatically. Vault writes every request to an audit log.
An integrity check watches accounts, sudo, SSH keys, cron and systemd
units, and posts changes made outside a deployment into the chat. Alerts
go to Matrix #infra-alerts; if Vault is sealed or the chat
itself is down, the alert goes out over Telegram instead.
The honest trade-offs
The main server is a single point of failure. Vault has its own server and backups have theirs, but every application runs on one machine. A provider incident, a disk failure or a bad kernel update there takes the company’s tools down together. The design makes a further split possible one line at a time. Vault went first, so the system that protects the secrets no longer shares a machine with the services that use them.
There is no managed SLA. Nobody is paged for us. We are the platform team: upgrades, certificate problems, disk pressure, Vault unsealing after a reboot, the admin token that has to be renewed before it expires, the weekly scan report. The €10 a month is hosting. The real cost is the engineering time to build it (about one engineer-week) and the recurring attention to keep it healthy.
Postgres is self-managed. There is no managed failover, no point-in-time recovery beyond the daily snapshot, and no support line to call. We get a database we control completely, at the price of owning its maintenance.
The recovery story has a gap. Weekly verification is real, the copy on a separate backup server is real, and the full restore drill is not done. Pretending otherwise would be the most expensive kind of marketing.
Some things are still on the list. Run the full disaster-recovery drill. Repoint the public website snippet from PostHog EU Cloud to our self-hosted instance. Neither is a surprise; both are written down.
Rebuildable by an agent
One design constraint has paid for itself more than any other: the infrastructure repository is written so that a competent stranger, human or AI agent, can rebuild the stack from scratch using only the documentation. That sounds like a documentation preference. It is really a bus-factor and recovery decision.
To make it work, every step has to be explicit. Versions are pinned. The bootstrap script is resumable and gates on the secrets a human must supply. The deploy commands are per service. The README explains the order, the manual steps and the failure modes, including the ones that are still open. That is also why the repository has grown to roughly 1,900 lines of documentation for 18 services: the documentation is part of the product, not an afterthought.
The evidence is recent. The infrastructure repository was initialized on 2 November 2026 and had about 60 commits by 11 November. The CRM fork dates from 28 October, and the website dropped its third-party CRM integration on 31 October. One week to build, one day to deploy, a few days to move data and onboard the team. The stack is now documented well enough that handing it to a client is a copy of the repository plus the handover process, not a rewrite.
Want this stack for your company?
We build infrastructure like this for clients: the same Ansible repository, the same Vault-managed secrets, the same backup and verification discipline, handed over documented and rebuildable. If you want to know what your version would look like, and what it would cost in hosting and engineering time, write to us.