Skip to content

Upgrading

FreeSDN uses CalVer versioning in the format YYYY.MM.PATCH (e.g. 2026.06.01). Releases are tagged in the repository with a leading v (e.g. v2026.06.01). There is no automatic update mechanism - upgrades are a deliberate operator action.

Never upgrade without a fresh database snapshot:

Terminal window
docker compose --env-file .env.pro exec pg-backup bash -c '
STAMP=$(date +%Y%m%d_%H%M%S)
pg_dump -h postgres -U "$PGUSER" -d "${POSTGRES_DB:-freesdn}" \
| gzip > /backups/preupgrade_freesdn_$STAMP.sql.gz
pg_dump -h logdb -U "$LOGDB_USER" -d "$LOGDB_DB" \
| gzip > /backups/preupgrade_logdb_$STAMP.sql.gz
echo "Done: $(ls -lh /backups/preupgrade_*.sql.gz)"
'
Terminal window
# 1. Pull the repo at the new release tag
git fetch --tags
git checkout v2026.06.01 # substitute the target release tag
# 2. Pull / rebuild images
docker compose --env-file .env.pro build --pull
# 3. Bring the new containers up (rolling restart)
docker compose --env-file .env.pro up -d
# 4. Migrations run automatically on API startup
# To confirm the schema reached the expected head:
docker compose --env-file .env.pro exec api alembic current
# Expected output ends with (head)
# 5. Smoke-test
curl -fsS http://localhost:8000/api/v1/health/ready

Alembic migrations are applied automatically by the api container at startup via scripts/migrate.py. The script is idempotent and safe to run on a stack that is already at head.

DB stateAction taken
Fresh DB (no alembic_version table)Runs create_all from ORM, then alembic stamp head. Skips incremental migrations entirely.
DB already at headPrints “Schema already at head” and exits - no-op.
DB at a known older revisionRuns alembic upgrade head from that revision.
Pre-consolidation revisionRewrites alembic_version directly to the current head revision and exits - no incremental migrations are run.

To check the current migration state without upgrading:

Terminal window
docker compose --env-file .env.pro exec api alembic current
docker compose --env-file .env.pro exec api alembic history

Downgrade scripts exist for every revision but are best-effort. Verify the target revision’s downgrade() function is non-destructive before running in production. Only downgrade one step at a time and test on a copy of the database first.

Terminal window
# Back one revision
docker compose --env-file .env.pro exec api alembic downgrade -1
# Back to a specific named revision
docker compose --env-file .env.pro exec api alembic downgrade 015_access_control_hardening

Minor Postgres upgrades (e.g. 18.4 → 18.5) are file-format compatible - no pg_upgrade needed.

Terminal window
# 1. Take a full pre-upgrade snapshot (see above)
# 2. Update the image tag in your env file or docker-compose.yml override:
# postgres: postgres:18.5-trixie
# 3. Stop the stack
docker compose --env-file .env.pro down
# 4. Bring Postgres up alone and confirm it starts healthy
docker compose --env-file .env.pro up -d postgres
docker compose --env-file .env.pro logs --tail=50 postgres
# 5. Confirm version and data integrity
docker compose --env-file .env.pro exec postgres \
bash -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version(); SELECT count(*) FROM core.users;"'
# 6. Bring the rest of the stack up
docker compose --env-file .env.pro up -d

Major Postgres upgrades (e.g. 18 → 19) require a pg_dump + pg_restore to a new volume. Always perform the upgrade on a staging clone before running it in production.

When upgrading a vendor adapter (e.g. pulling a new Omada adapter version), always test with ADAPTER_READ_ONLY=true first. The default is already true, but verify:

Terminal window
docker compose --env-file .env.pro exec api \
python -c "from app.core.config import settings; print('ADAPTER_READ_ONLY=', settings.ADAPTER_READ_ONLY)"

With ADAPTER_READ_ONLY=true, direct writes to the live controller are refused by each adapter client layer. The only path that reaches a device is an explicit staged apply - AdapterStagingService.apply_change - which enforces a true dual-gate, in order: ADAPTER_READ_ONLY is a hard environment lock that is refused regardless of force (force cannot bypass it); only once that lock is open (ADAPTER_READ_ONLY=false) does the second gate apply, requiring force=true. So ADAPTER_READ_ONLY=true is a hard block on every live write, staged or not - passing force=true does not override it. UI-authored mutations still queue as pending changes; they simply cannot be pushed until an operator explicitly applies them with force=true. This lets you run sync, discovery, and read operations against a real controller without risking accidental configuration changes.

  1. Deploy with ADAPTER_READ_ONLY=true.
  2. Run sync against a single controller of the affected vendor.
  3. Confirm freesdn_adapter_errors_total does not spike and circuit breakers stay closed for at least 30 minutes.
  4. Only then flip ADAPTER_READ_ONLY=false and restart. Schedule this outside business hours.

Never adopt a Python or Node package release on the day it ships. Wait 72 hours. This window catches withdrawn releases, missing distribution files, and compromised maintainer incidents that are common on PyPI and npm the day a new version appears. Security CVE patches override this rule - apply them as soon as the patch is signed by upstream.

Checking for migration conflicts after a merge

Section titled “Checking for migration conflicts after a merge”

If two branches each added a migration and both end up in alembic/versions/, alembic upgrade head will error with “Multiple head revisions are present”. Linearize the chain by editing the down_revision of the newer migration to point at the older migration’s revision ID, then re-run alembic heads to confirm a single head before applying.

Never use alembic merge in production without first testing the merged revision on a copy of the production database - the auto-generated merge revision is a no-op that masks the conflict rather than resolving it.