Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
880 changes: 880 additions & 0 deletions docs/superpowers/plans/2026-09-23-portabase-db-backups.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions docs/superpowers/specs/2026-09-20-portabase-db-backups-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ an alias that shells out to `docker exec ... pg_dumpall` and gzips the result in
along with it. Keeping Node on the host purely to schedule database dumps is the cost we
want to remove.

Cronicle is also stuck at v0.9.59: `roles/init_setup/tasks/cronicle.yml` only imports the
Cronicle upgrades are also awkward: `roles/init_setup/tasks/cronicle.yml` only imports the
installer `when: cronicle_service_check_result.failed`, and the installer's unarchive task
is additionally guarded by `creates: /opt/cronicle/package.json`. A healthy service is
therefore never upgraded.
is additionally guarded by `creates: /opt/cronicle/package.json`, so a healthy service is
not upgraded by a normal run. The host now runs Cronicle v0.9.134 on Node.js 24.

[Portabase](https://github.com/Portabase/portabase) (Apache-2.0, actively developed) does
the specific job Cronicle is being used for — scheduled PostgreSQL dumps with retention,
Expand Down Expand Up @@ -99,7 +99,8 @@ Portabase's "local" storage channel writes inside the **dashboard** container, a
the agent. So the host path is bind-mounted into the dashboard:

- `{{ p_dirs.backups_root }}/portabase` → `/data` on `portabase-app`
- dumps land in `{{ p_dirs.backups_root }}/portabase/uploads/`
- dumps land in `{{ p_dirs.backups_root }}/portabase/private/uploads/` (`PRIVATE_PATH`
stays at the image default `/data/private`; the entrypoint hardcodes that tree for tusd)
- `{{ p_dirs.backups_root }}/db_dumps` stays Cronicle's, untouched

Portabase's own state (its PostgreSQL) lives on the fast pool as a dedicated ZFS dataset at
Expand Down Expand Up @@ -136,8 +137,9 @@ Node.js and Cronicle task imports. Flipping it to `false` is the first step of r
taken later and separately.

Unrelated fix already applied: NodeSource is no longer a per-distribution repository, so
`install_node.yml` must use the suite `nodistro` rather than the distribution codename
(`dists/jammy` returns 404, `dists/nodistro` returns 200).
`install_node.yml` uses the suite `nodistro` rather than the distribution codename
(`dists/jammy` returns 404, `dists/nodistro` returns 200). With that fix the Node.js 24 and
Cronicle v0.9.134 upgrades deployed successfully.

## Bootstrap

Expand Down
10 changes: 10 additions & 0 deletions main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,16 @@
- applications
- planka

- name: Deploy 'portabase'
ansible.builtin.import_role:
name: deploy_portabase
vars:
pbs_app_host: 'portabase.{{ p_base_domain }}'
pbs_edge_key: '{{ v_portabase.edge_key }}'
tags:
- applications
- portabase

- name: Deploy 'opencloud'
ansible.builtin.import_role:
name: deploy_opencloud
Expand Down
9 changes: 9 additions & 0 deletions roles/deploy_authentik/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,17 @@ services:
POSTGRES_DB: ${PG_DB:?database name required}
env_file:
- .env
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_forgejo/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ services:
- POSTGRES_DB=forgejo
volumes:
- ${APP_DATA_PATH}/postgres:/var/lib/postgresql/data
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_ghostfolio/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ services:
retries: 5
volumes:
- ${APP_DATA_PATH}/db:/var/lib/postgresql/data
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

redis:
image: docker.io/library/redis:${REDIS_VERSION}
Expand Down Expand Up @@ -81,3 +87,6 @@ networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_immich/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,17 @@ services:
volumes:
- ${APP_DATA_PATH}/db:/var/lib/postgresql/data
shm_size: 128mb
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_koillection/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@ services:
volumes:
- '${APP_DATA_PATH}/db:/var/lib/postgresql/data'
restart: unless-stopped
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_linkwarden/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ services:
restart: unless-stopped
volumes:
- ${APP_DATA_PATH}/db:/var/lib/postgresql/data
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup
meilisearch:
image: getmeili/meilisearch:${MEILISEARCH_VERSION}
container_name: 'linkwarden_meilisearch'
Expand All @@ -43,3 +49,6 @@ networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_n8n/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ services:
interval: 5s
timeout: 5s
retries: 10
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

n8n-redis:
image: redis:${REDIS_VERSION}
Expand Down Expand Up @@ -79,3 +85,6 @@ networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_paperless/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ services:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:${APP_VERSION}
Expand Down Expand Up @@ -93,3 +99,6 @@ networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
9 changes: 9 additions & 0 deletions roles/deploy_planka/files/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,17 @@ services:
interval: 10s
timeout: 5s
retries: 5
networks:
# 'default' must stay listed: a service that declares networks no longer
# joins it implicitly, and the app reaches its DB over 'default'.
- default
# Dump-only path for the Portabase agent. Internal network, no egress.
- db_backup

networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
75 changes: 75 additions & 0 deletions roles/deploy_portabase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# deploy_portabase

Scheduled PostgreSQL backups with retention and restore, replacing the Cronicle `d-db-dump` jobs.

Three containers: the dashboard (`portabase-app`), its own PostgreSQL (`portabase-pg`), and the
agent (`portabase-agent`) that actually runs `pg_dump`.

## Networking

The agent is a plain PostgreSQL client — it runs `pg_dump --host <container> --port 5432`. It
reaches application databases over the `db_backup` network, created by `setup_docker` with
`internal: true` so it has no route off the host. The agent is deliberately **not** on
`nginxnetwork`, and has **no docker socket** (that is only needed for Docker Volume backups).

A database joins by listing both `default` and `db_backup` on its own service in the app's
`compose.yml`. A service that declares `networks:` stops joining `default` implicitly, so both must
be listed.

## Storage

Dumps go to `{{ p_dirs.backups_root }}/portabase`, mounted as `/data` on the dashboard. Portabase's
`local` storage provider writes to `PRIVATE_PATH/uploads` **inside the dashboard container**, not
the agent. `PRIVATE_PATH` is left at the image default `/data/private`, so files appear at
`{{ p_dirs.backups_root }}/portabase/private/uploads/`. The dashboard container runs as root
(upstream's prod stage ends with `USER root`), so the dumps it writes are root-owned.

Cronicle's `{{ p_dirs.backups_root }}/db_dumps` is untouched; both systems run in parallel.

## First-time bootstrap

`EDGE_KEY` is minted by the dashboard, so the first deploy is necessarily two-phase.

1. Deploy: `ansible-playbook main.yml --tags portabase`. The dashboard and its database come up.
The agent starts and fails to pair — expected.
2. Open `https://portabase.<domain>`, log in with `v_portabase.admin_email` /
`v_portabase.admin_password`.
3. Create an agent in the UI, copy its `EDGE_KEY`, store it as `v_portabase.edge_key`:
`ansible-vault edit vars/vault.yml`.
4. Re-deploy: `ansible-playbook main.yml --tags portabase`. Confirm pairing with
`docker logs portabase-agent` and the agent list in the UI.
5. In the UI, create a storage channel of type **local**.
6. Add each database. Host is the container name, port `5432`; credentials come from that app's
`.env` in `/mnt/pools/fast/docker/compose-files/<app>/.env`:

| App | Host | User variable | Password variable | Database variable |
|---|---|---|---|---|
| authentik | `authentik-postgres` | `PG_USER` | `PG_PASS` | `PG_DB` |
| forgejo | `forgejo_pg` | `forgejo` (literal) | `forgejo` (literal) | `forgejo` (literal) |
| ghostfolio | `ghostfolio-postgres` | `POSTGRES_USER` | `POSTGRES_PASSWORD` | `POSTGRES_DB` |
| immich | `immich_postgres` | `DB_USERNAME` | `DB_PASSWORD` | `DB_DATABASE_NAME` |
| koillection | `koillection_db` | `DB_USER` | `DB_PASSWORD` | `DB_NAME` |
| linkwarden | `linkwarden_db` | `postgres` (literal) | `POSTGRES_PASSWORD` | `postgres` (literal) |
| n8n | `n8n-db` | `DB_USER` | `DB_PASSWORD` | `DB_NAME` |
| paperless | `paperless_db` | `paperless` (literal) | `paperless` (literal) | `paperless` (literal) |
| planka | `planka-db` | `postgres` (literal) | *(none — trust auth)* | `planka` (literal) |
| semaphore | `semaphore_pg` | `PG_USER` | `PG_PASSWORD` | `PG_DB_NAME` |

7. Set a schedule and retention per database, then run one backup by hand to confirm it lands in
`{{ p_dirs.backups_root }}/portabase/private/uploads/`.

## Caveats

- **Immich** runs `ghcr.io/immich-app/postgres` (VectorChord). `pg_dump` works, but restoring needs
an image carrying the same extension — a stock `postgres:17` will fail on restore.
- **Planka** uses `POSTGRES_HOST_AUTH_METHOD: trust` and has no password set.
- Redis and Valkey containers are not enrolled: Portabase can back them up but cannot restore them.
- Backup definitions live in Portabase's own database, not in Ansible. This role deploys the
platform; the per-database configuration is UI state. Restoring Portabase itself means restoring
`{{ p_dirs.apps_data }}/portabase/db`.

## Retiring Cronicle

Once the Portabase dumps have been trusted for a while: set `isu_cronicle_enabled: false` in
`roles/init_setup/defaults/main.yml`, delete the events in the Cronicle UI, then remove the host
install (`systemctl disable --now cronicle`, `rm -rf /opt/cronicle`) by hand.
17 changes: 17 additions & 0 deletions roles/deploy_portabase/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
# Dashboard (Next.js control plane) and its own PostgreSQL.
pbs_app_version: '1.30.2'
pbs_db_version: '17-alpine'

# Rust agent: runs pg_dump against the application databases.
pbs_agent_version: '1.21.2'

pbs_app_host: placeholder

# Pairing token minted by the dashboard when an agent is created in its UI.
# Empty on a first deploy — the agent cannot pair until this is filled in from
# the vault. See the role README for the two-phase bootstrap.
pbs_edge_key: ''

# Retention sweep for expired backups, as a cron expression.
pbs_retention_cron: '0 * * * *'
82 changes: 82 additions & 0 deletions roles/deploy_portabase/files/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
services:
portabase-app:
image: portabase/portabase:${APP_VERSION}
container_name: 'portabase-app'
hostname: 'portabase-app'
env_file:
- .env
environment:
TZ: ${TZ}
volumes:
# Portabase's 'local' storage channel writes to PRIVATE_PATH/uploads
# inside THIS container (not the agent), so the slow-pool backup path is
# mounted here. Dumps land in ${BACKUPS_PATH}/private/uploads on the host.
# No `user:` here, unlike portabase-pg: the upstream image ends its prod
# stage with USER root and its entrypoint execs nginx without dropping
# privileges, so pinning a UID would fight the image rather than help.
- ${BACKUPS_PATH}:/data
networks:
- default
- portabase
restart: unless-stopped
depends_on:
portabase-pg:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'curl -f http://localhost/api/health']
interval: 30s
timeout: 5s
retries: 3
start_period: 60s

portabase-pg:
image: postgres:${DB_VERSION}
container_name: 'portabase-pg'
hostname: 'portabase-pg'
user: '${APP_USER}:${APP_GROUP}'
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ${APP_DATA_PATH}/db:/var/lib/postgresql/data
# Deliberately NOT on 'default' (nginxnetwork): Portabase's own state is
# reachable only by the dashboard.
networks:
- portabase
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5

portabase-agent:
image: portabase/agent:${AGENT_VERSION}
container_name: 'portabase-agent'
hostname: 'portabase-agent'
environment:
EDGE_KEY: ${EDGE_KEY}
LOG: info
TZ: ${TZ}
# No docker socket: the agent runs pg_dump as a network client
# (agent-rust/src/domain/postgres/backup.rs). The socket is only needed for
# Docker Volume backups, which are out of scope.
networks:
# Reaches the dashboard to poll for jobs...
- portabase
# ...and the application databases to dump them. No egress from here.
- db_backup
restart: unless-stopped
depends_on:
portabase-app:
condition: service_healthy

networks:
default:
external: true
name: nginxnetwork
db_backup:
external: true
name: db_backup
portabase:
Loading