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.
Before every upgrade: take a backup
Section titled “Before every upgrade: take a backup”Never upgrade without a fresh database snapshot:
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)"'Standard upgrade procedure
Section titled “Standard upgrade procedure”# 1. Pull the repo at the new release taggit fetch --tagsgit checkout v2026.06.01 # substitute the target release tag
# 2. Pull / rebuild imagesdocker 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-testcurl -fsS http://localhost:8000/api/v1/health/readyHow migrations work
Section titled “How migrations work”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.
What migrate.py detects
Section titled “What migrate.py detects”| DB state | Action taken |
|---|---|
Fresh DB (no alembic_version table) | Runs create_all from ORM, then alembic stamp head. Skips incremental migrations entirely. |
| DB already at head | Prints “Schema already at head” and exits - no-op. |
| DB at a known older revision | Runs alembic upgrade head from that revision. |
| Pre-consolidation revision | Rewrites alembic_version directly to the current head revision and exits - no incremental migrations are run. |
To check the current migration state without upgrading:
docker compose --env-file .env.pro exec api alembic currentdocker compose --env-file .env.pro exec api alembic historyDowngrading (surgical rollback only)
Section titled “Downgrading (surgical rollback only)”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.
# Back one revisiondocker compose --env-file .env.pro exec api alembic downgrade -1
# Back to a specific named revisiondocker compose --env-file .env.pro exec api alembic downgrade 015_access_control_hardeningPostgres minor-version upgrades
Section titled “Postgres minor-version upgrades”Minor Postgres upgrades (e.g. 18.4 → 18.5) are file-format compatible - no pg_upgrade needed.
# 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 stackdocker compose --env-file .env.pro down
# 4. Bring Postgres up alone and confirm it starts healthydocker compose --env-file .env.pro up -d postgresdocker compose --env-file .env.pro logs --tail=50 postgres
# 5. Confirm version and data integritydocker 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 updocker compose --env-file .env.pro up -dMajor 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.
Testing adapter changes safely
Section titled “Testing adapter changes safely”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:
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.
- Deploy with
ADAPTER_READ_ONLY=true. - Run sync against a single controller of the affected vendor.
- Confirm
freesdn_adapter_errors_totaldoes not spike and circuit breakers stay closed for at least 30 minutes. - Only then flip
ADAPTER_READ_ONLY=falseand restart. Schedule this outside business hours.
The 3-day rule for dependency updates
Section titled “The 3-day rule for dependency updates”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.