A data service pattern
A data service pattern
Goal: Run one data service—PostgreSQL by default—declaratively on NixOS: module options, state directories, app role/auth via secrets paths, and a written backup story (even if automation is minimal).
DB passwords go through sops-nix paths, not Nix strings. Examples use placeholders and path references only.
Why this chapter exists
Application servers without state are easy. Real systems need durable data:
| Concern | NixOS angle |
|---|---|
| Package + unit | services.postgresql module |
| State location | services.postgresql.dataDir / ensure on persistent disk |
| Auth | peer/scram; password files from secrets |
| Network exposure | localhost first (firewall discipline) |
| Backups | Not automatic just because Nix is declarative |
Nix rebuilds the software and config; it does not magically version your rows.
Theory 1 — One DB only (pattern depth)
Depth > zoo. We use PostgreSQL. If you already run another engine at work, the pattern transfers: module → state dir → credentials → firewall → backups.
Theory 2 — Minimal PostgreSQL module
# modules/services/postgres.nix
{ config, pkgs, ... }:
{
services.postgresql = {
enable = true;
package = pkgs.postgresql_16; # pick a version available on 26.05 pin
ensureDatabases = [ "labapp" ];
ensureUsers = [
{
name = "labapp";
ensureDBOwnership = true;
}
];
settings = {
# listen_addresses = "localhost"; # confirm option name on your version
};
};
# Do NOT open 5432 on the host firewall for world access in the default lab.
}Verify option names against search.nixos.org for 26.05.
Theory 3 — State directories and disk
PostgreSQL data lives outside the Nix store (mutable).
| Question | Answer you must know |
|---|---|
Where is dataDir? |
Document path |
| Is it on the disk that survives reinstall? | Ops/impermanence later |
| What happens on rollback of system generation? | Software rolls back; data may not |
Impermanence hosts need explicit persistence for DB dirs—note the footgun now.
sudo -u postgres psql -c 'SHOW data_directory;'Theory 4 — Credentials
Patterns:
- Peer auth for local system users (good for admin)
- SCRAM password for app users—password from sops at runtime
- Socket-only access for local apps
Sketch with sops (illustrative—align with module capabilities):
sops.secrets."postgres/labapp_password" = {
owner = "postgres";
mode = "0400";
};
# Prefer module-supported passwordFile style options when available.
# If you script role passwords, run a oneshot that reads the secret path
# and does not print it.Never:
# BAD
# services.postgresql.ensureUsers = [{ name = "labapp"; password = "secret"; }];Theory 5 — App connection story
App config should receive:
- Host:
127.0.0.1or socket - DB name:
labapp - User:
labapp - Password: from env file populated at activation from sops
Your TLS front door proxy does not replace DB auth. Backend talks to DB on localhost; users talk to proxy on 443.
Theory 6 — Backup thought (required cognitive work)
Even a one-node lab needs a plan:
| Element | Example |
|---|---|
| What | pg_dump of labapp |
| When | nightly timer (system or user) |
| Where | /var/backups/postgres/ on another disk or remote |
| Encryption | restic/age to remote (secrets via sops) |
| Restore test | once per quarter / before capstone |
sudo -u postgres pg_dump labapp > /tmp/labapp-dump.sql
# store off-box in real life; scrub /tmp laterTheory 7 — Upgrades
Postgres major upgrades are not “change package attr and forget.” They need dump/restore or pg_upgrade procedures. Pin a major version intentionally; schedule upgrades.
Theory 8 — ensureDatabases vs migrations
ensureDatabases / ensureUsers create empty roles/DBs. Application schema is not Nix’s job by default.
| Layer | Owner |
|---|---|
| Package + unit + listen policy | NixOS module |
| Roles/DBs existence | ensure* options / oneshots |
| Schema / migrations | App tooling (flyway, django, …) |
| Data | Backups |
Theory 9 — Socket vs TCP local connections
Postgres often exposes a Unix socket under /run/postgresql. Local admin via sudo -u postgres psql uses peer auth. Apps may use:
host=127.0.0.1 port=5432
# or host=/run/postgresql
Pick one, document in docs/data-service.md, match app secret env.
Worked example — Service + docs
modules/services/postgres.nix
docs/data-service.md
- version
- dataDir
- DB/user names
- auth method
- backup sketch
- restore sketch
Worked example — Timer sketch for dumps (optional)
{ pkgs, ... }:
{
systemd.services.labapp-pg-dump = {
serviceConfig = {
Type = "oneshot";
User = "postgres";
ExecStart = "${pkgs.bash}/bin/bash -c '${pkgs.postgresql}/bin/pg_dump labapp | ${pkgs.gzip}/bin/gzip -c > /var/backups/labapp.sql.gz'";
};
};
# + timer + StateDirectory/tmpfiles for /var/backups
}Wire carefully (permissions, disk space). Even a commented sketch in the flake beats “we’ll back up later.”
Exercises
Exercise 1 — Enable PostgreSQL
Add module; rebuild; verify unit:
sudo systemctl status postgresql
sudo -u postgres psql -c '\l'Exercise 2 — Database and role
Confirm labapp DB and user via ensure* options. Connect:
sudo -u postgres psql -d labapp -c 'SELECT 1;'Exercise 3 — Wire a secret password path
Create sops secret placeholder for app password. Configure the safest supported method for your module version. Prove app user can authenticate without password living in the flake as plaintext.
Exercise 4 — Network posture
ss -tulpn | grep 5432 || trueConfirm listening on localhost (or document intentional LAN bind). Ensure firewall does not expose 5432 publicly.
Update docs/ports.md.
Exercise 5 — Manual dump
sudo -u postgres pg_dump labapp | headWrite restore commands in docs/data-service.md.
Exercise 6 — Failure narrative
Answer:
- System generation rollback after a bad app deploy—does data revert?
- Disk failure—what is lost without backups?
- Secret rotation steps for DB password
Exercise 7 — Schema independence proof
sudo -u postgres psql -d labapp -c "CREATE TABLE lab_demo(id int);"
sudo -u postgres psql -d labapp -c '\dt'
sudo nixos-rebuild switch --flake .#lab
sudo -u postgres psql -d labapp -c '\dt'Table should still exist—state ≠ generation.
Exercise 8 — Document restore dry-run
# restore sketch
# sudo -u postgres psql -d labapp < labapp-dump.sqlExercise 9 — dataDir documentation
sudo -u postgres psql -c 'SHOW data_directory;'Record in docs.
Exercise 10 — Peer auth admin path
Document how you admin without password in git (sudo -u postgres psql).
Exercise 11 — Package pin note
Write which postgresql_* package attr you chose and why you will not casual-bump majors.
Exercise 12 — Backup location permissions
sudo mkdir -p /var/backups/postgres
# ownership suitable for dump userExercise 13 — App connection string template
Write a template with path reference to password file—not the password.
Exercise 14 — ports.md final for DB
Confirm 5432 is localhost-only in docs and reality.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Data in store myth | Data is on mutable disk |
| Open 5432 to world | Bind localhost; close firewall |
| Password in nix | sops path / peer auth |
| Major version bump casual | Plan dump/restore |
| No backup thought | Exercises 5–6 required |
| Proxying Postgres over HTTP | Wrong layer; don’t |
| ensure* expected to migrate schema | Use app migrations |
| Rollback “restored” DB | Generations ≠ data |
| Dump committed with secrets | Scrub; don’t commit dumps |
Checkpoint
- PostgreSQL enabled declaratively
- App DB + user exist
- Auth without plaintext secret in git
- Localhost exposure default
- Manual dump performed once
- Backup/restore story written
- ports.md updated
- Schema-vs-generation understood
Journal (optional)
Write three personal gotchas before continuing.