Skip to content

Troubleshooting

Use this guide when a Safe Harbor install, update, backup, restore, or tunnel does not behave as expected. Run commands from the directory that contains docker-compose.yml.

Stack won't come up after docker compose up -d

Start with container state and the web/Postgres logs:

docker compose ps
docker compose logs --tail=100 web
docker compose logs --tail=100 postgres

Common causes are a host port conflict on 8000, a missing .env, or a bad DATABASE_URL. In the default Compose stack, DATABASE_URL should point at the Compose service name postgres, not localhost. After fixing the cause, run:

docker compose up -d
docker compose logs --tail=100 web

Empty parameter columns in batch entry

Older fresh installs could show empty measurement parameter columns because reference tables were not seeded. The entrypoint now runs safeharbor seed automatically after migrations, so new installs should have units, parameter types, and parameter ranges before the app starts.

If the columns are still empty, run the seed manually and inspect its result:

docker compose exec web flask --app safeharbor.wsgi:app safeharbor seed
echo $?
docker compose logs --tail=100 web

First-run wizard not showing

A brand-new install should redirect to /setup until the first administrator exists. If it does not, inspect logs and database connectivity:

docker compose logs --tail=100 web
docker compose exec postgres pg_isready -U safeharbor
docker compose exec web flask --app safeharbor.wsgi:app shell

Inside the Flask shell:

from safeharbor.models.account import User
User.query.count()

If the count is 0, /setup should be available. If the count is greater than 0, the wizard is correctly hidden.

create-admin CLI as a headless alternate

Use the CLI when the browser setup flow is unavailable, the host is headless, or automation needs to create the first administrator after the stack is healthy.

docker compose exec web flask --app safeharbor.wsgi:app safeharbor create-admin

On a new install it prompts:

Email:
Password:
Confirm password:
Unit system [imperial]:

Enter an administrator email address, a password of at least 10 characters, the same password again, and either imperial or metric. The command refuses to run after any user exists.

Pre-upgrade backup didn't run on docker compose pull

docker compose pull only downloads images. The pre-upgrade backup check runs when a new web or worker container starts and the entrypoint sees that the current database revision differs from the migration heads.

docker compose exec web flask --app safeharbor.wsgi:app db current
docker compose exec web flask --app safeharbor.wsgi:app db heads
docker compose exec web sh -c 'test -w /backups && echo writable'
docker compose logs --tail=150 web

If current already matches heads, no pre-upgrade backup is expected. During startup with pending migrations, a log line of pre-upgrade backup failed; continuing usually means the host ./backups bind mount is missing or not writable by the container user. Use the install guide's backup directory pre-create command:

mkdir -p backups && sudo chown -R 1000:1000 backups
docker compose up -d --force-recreate web worker

Restore fails with Postgres version mismatch

Safe Harbor backup tarballs wrap a PostgreSQL custom-format dump at db.dump plus the saved uploads/ tree. The embedded dump follows PostgreSQL dump/restore compatibility rules, so restoring with an older pg_restore than the source pg_dump can fail.

Before a destructive restore, ask Safe Harbor to inspect the archive with a dry-run:

docker compose --profile backup exec backups flask safeharbor restore --from /backups/<filename>.tar --dry-run

For advanced archive inspection, extract db.dump from the tarball into a temporary directory before running pg_restore --list:

mkdir -p /tmp/safeharbor-restore-check
tar -xf /path/to/<filename>.tar -C /tmp/safeharbor-restore-check db.dump
pg_restore --list /tmp/safeharbor-restore-check/db.dump

If the restore tool is too old for the archive, upgrade the Postgres image or restore into a matching newer Postgres container before running the normal restore command.

Cloudflare Tunnel won't connect or returns 502

Check both sides of the tunnel:

docker compose logs --tail=100 cloudflared
docker compose logs --tail=100 web

Likely causes are a missing or wrong TUNNEL_TOKEN, a public hostname in the Cloudflare dashboard that does not point to http://web:8000, missing TRUST_PROXY_HEADERS=1, or a FORWARDED_ALLOW_IPS value that excludes the proxy path. Dynamic tunnel environments commonly need the default * unless you have a stable proxy IP.

After changing .env, recreate the affected containers:

docker compose up -d --force-recreate web cloudflared

Backups don't run

Scheduled backups run only when the backup Compose profile is active and Ofelia is running. Check .env:

COMPOSE_PROFILES=backup
BACKUP_SCHEDULE=0 0 3 * * *

BACKUP_SCHEDULE must be a six-field cron expression: second minute hour day-of-month month day-of-week.

docker compose --profile backup ps ofelia backups
docker compose --profile backup logs --tail=100 ofelia
docker compose --profile backup exec backups sh -c 'test -w /backups && echo writable'

If /backups is not writable, fix the host bind mount:

mkdir -p backups && sudo chown -R 1000:1000 backups

GHCR pull fails

If the container package or requested tag is private, GHCR image pulls require authentication:

docker login ghcr.io
docker compose pull

If the package is public, anonymous pulls should work for normal installs. If pulls still fail, check authentication state, the requested tag, network restrictions, and registry rate limits.

flask safeharbor seed fails on re-run

The seed command should be idempotent. Existing units, parameter types, and parameter ranges are matched by natural keys and skipped instead of duplicated.

Capture the exact failing command, exit status, and stderr:

docker compose exec web flask --app safeharbor.wsgi:app safeharbor seed
echo $?
docker compose logs --tail=100 web

Open a GitHub issue with that output, the Safe Harbor image tag, and the natural key mentioned in the conflict. Do not manually delete reference rows unless you have a database backup and understand which measurements depend on them.