Anatomy of a €10/Month Company Stack: Ansible, Vault, Postgres and 15 More Services

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.

Diagram: Internet through Caddy to a grid of services on an internal network, Vault marked as running on its own server, with 6 operational services below
18 services: 12 that people use and 6 that keep the rest running.

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.

Network diagram of three servers and four zones. On the main server: the public edge through Caddy to public apps and allow-listed admin pages, an internal network with PostgreSQL and Redis, an admin path over SSH keys and WireGuard, and Vault Agents that render secrets into memory-only files. Vault runs on its own server. Backups go through restic to the server disk and to MinIO on a separate backup server
Three servers, four zones: what the internet can reach, what only the services can reach, and how admins and secrets get in.

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.

Five steps: Ansible generates each secret once, Vault holds one document, policy and AppRole per service, a Vault Agent per service renders the secrets into a memory-only directory, and the container mounts only its own directory read-only
How a secret reaches a service: from Vault to a memory-only file, never through a file that persists.

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.

Three columns: a daily encrypted restic snapshot to the server disk and to our own MinIO on a separate server, a Sunday verification that reads back 20% and restores the Postgres dump into a throwaway container, and the full restore drill that is not done yet
Backups: what runs every day, what is checked every Sunday, and what is not done yet.

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.

Talk to us about your stack →