Skip to content
CodeFloe

Database

Alright, let’s talk about the “heart” of each service: the database !

To start off, let’s address the most prominent point: using a managed service or rolling our own. This question will likely be there until humanity exists and surely, we also thought about it when starting out. Outlining all arguments for each case would fill multiple pages, so we’ll focus on explaining what we’ve decided to do and why:

We went with Postgres deployed through autobase on individual VMs in a HA-setup.

Postgres is the de-facto standard for large-scale DB needs. It is FOSS, used by the majority of projects (likely) and has a strong ecosystem around extensions and tools (like autobase) which make it realistic to administrate a large DB at scale. In addition, maintaining a self-hosted instance likely saves the project from financial death. The costs for managed DBs (compared to self-hosted ones) are super high. And once the instance requirements grow, the costs grow with it exponentially.

We believe that it must be possible to run a production-grade Postgres service in 2025+ without a managed service.

And just to get something clear: we are not just starting out with Postgres 😉️ We have substantial experience in running databases, including Postgres, in production environments. Yet, “production” is always different and a project like CodeFloe has the potential to grow to another level we have not experienced before. This means both excitement and challenges ahead! But we are confident that we can handle it, together with the community and an open & transparent approach.

The database is run in a three-node setup with automatic failover and replication being orchestrated through patroni. Backups are executed via pgbackrest to two distinct (S3) locations. autobase allows to add additional nodes to the cluster if needed. We can also perform seamless major upgrades by just executing an Ansible playbook.

Each node has a connection pooler (pgbouncer) in front. On top, each pooler has a load balancer (LB) (HAProxy) in front which serves as the central entrypoint for any traffic. The LB is able to contact the connection pooler instances in a “round-robin” fashion, ensuring it always finds a healthy one in case one of the poolers is down. In the same manner, any client can use the LBs as the primary connection point(s) for the database.

For example, Forgejo is able to make use of a so-called “EngineGroup” connection (starting with v12) which allows specifying multiple connections for primary and replica nodes, respectively. The connection pool subsequently contains the respective entrypoints of the HAProxy LBs sitting in front of the connection poolers.

Measured on 2026-09-15 with PostgreSQL/pgbench 18.6 and fio 3.36. Each database VM has 4 vCPUs (Intel Core i9-13900), 16 GiB RAM, and an 800 GiB XFS disk on local NVMe RAID1/LVM-thin with cache=none. Replication is asynchronous; local durable commits are enabled.

Run on the primary over a local Unix socket, with the benchmark client sharing the VM’s four vCPUs:

sudo -u postgres createdb pgbench_eval_20260915
sudo -u postgres /usr/pgsql-18/bin/pgbench -i -s 10 pgbench_eval_20260915
# Write workload: 30 clients, four threads, 120 seconds.
sudo -u postgres /usr/pgsql-18/bin/pgbench -c 30 -j 4 -T 120 -P 10 pgbench_eval_20260915
# Read workload.
sudo -u postgres /usr/pgsql-18/bin/pgbench -S -c 30 -j 4 -T 120 -P 10 pgbench_eval_20260915
sudo -u postgres dropdb pgbench_eval_20260915
Write TPSRead TPSAverage write latency (ms)Average read latency (ms)
8,051153,3673.7250.195

The small dataset fits in RAM, so read TPS primarily measures cached-query performance.

Run separately on each node with a temporary file on the database filesystem, using 8 KiB requests, a 3-second warm-up, and 20 seconds per profile:

# Prepare a fully written test file outside PostgreSQL's data directory.
bench_dir=$(sudo mktemp -d /var/lib/pgsql/io-eval.XXXXXX)
sudo fio --name=prepare --filename="$bench_dir/test.bin" --size=2G \
  --rw=write --bs=1M --ioengine=libaio --iodepth=8 --direct=1 --end_fsync=1

for mode in randread randwrite; do
  for depth in 1 32; do
    sudo fio --name="$mode-q$depth" --filename="$bench_dir/test.bin" \
      --size=2G --bs=8k --numjobs=1 --rw="$mode" --ioengine=libaio \
      --iodepth="$depth" --direct=1 --runtime=20 --ramp_time=3 \
      --time_based=1 --group_reporting --randrepeat=1 \
      --percentile_list=50:95:99:99.9
  done
done

# Flush after each write to measure the durability path.
sudo fio --name=sync-write --filename="$bench_dir/test.bin" \
  --size=2G --bs=8k --numjobs=1 --rw=write --ioengine=psync \
  --iodepth=1 --direct=0 --fdatasync=1 --runtime=20 --ramp_time=3 \
  --time_based=1 --group_reporting --randrepeat=1 \
  --percentile_list=50:95:99:99.9
sudo rm "$bench_dir/test.bin"
sudo rmdir "$bench_dir"

Results across the three nodes; queue depth (QD) is the number of outstanding requests:

OperationIOPS, QD1IOPS, QD32
Random read10,987-11,573272,818-296,286
Random write26,272-28,543235,895-243,210

Write-and-flush throughput was 18,469-20,080 cycles/s, with 0.063-0.091 ms p99 flush latency. Random-write IOPS do not include a flush per write; the separate flush test measures that cost.