Netbox Upgrade Analysis
Record of the NetBox upgrade carried out under sc-10975: the path taken, the breaking changes that applied, and the failure encountered along the way.
Status: complete
The upgrade is finished. NetBox is running v4.6.7.
This document is retained as the reference for future NetBox upgrades. The pre-flight method and the failure analysis are the parts worth reading before any subsequent version bump.
Scope
Our NetBox usage is minimal and IPAM-only: we track allocated IP networks and subnets. It is the
source of truth referenced by convention comments throughout the KCC VPC manifests, for example
k8s/infra-services/tb-infra-mgmt-vpc-project/compute/europe-west1-network.yaml.
That scope is what makes this a straight migration rather than a project. We are not using circuits, device modelling, virtualisation, L2VPN, config contexts, custom scripts, reports, or GraphQL — so the overwhelming majority of the breaking changes across nine releases do not apply to us.
Stepping through the intermediate NetBox version numbers on the path is accepted.
Starting state, before the upgrade
Recorded as it was at the outset. The deployment now runs v4.6.7, with an rqworker, probes,
strategy: Recreate, ArgoCD sync-wave ordering and a Redis NetworkPolicy — see
Netbox for the current shape.
| Property | Value | Source |
|---|---|---|
| Image | netboxcommunity/netbox:v3.5.9 (August 2023) | k8s/infra-services/netbox/base/netbox-deployment.yaml |
| Latest upstream | v4.6.7 (2026-07-30) | upstream |
| Latest v3 release | v3.7.8 | upstream |
| Replicas | 1 | base/netbox-deployment.yaml |
| Container port | 8080, Service :80 → http | base/netbox-deployment.yaml |
| Deployment strategy | default (RollingUpdate) | not set |
| Background worker | none — rqworker is commented out | base/netbox-deployment.yaml |
| Redis | redis:6.2.5, single instance, tasks db 0 + caching db 1 | base/netbox-redis.yaml, base/configmap.yaml |
| DB config style | legacy singular DATABASE = {...} | base/configmap.yaml |
| Delivery | ArgoCD Application netbox, automated sync, prune: true | argocd/overlays/infra-platform-cluster/apps/netbox.yaml |
| Ingress | Envoy Gateway HTTPRoute → https://netbox.infra.titanbay.com | envoy-gateway/overlays/infra-titanbay-com/routes/netbox.yaml |
Database
Cloud SQL, declared via Config Connector in
k8s/infra-services/tb-infra-mgmt-project/sql/cloudsql-postgres.yaml:
| Property | Value |
|---|---|
| Kind | SQLInstance infra-management |
| Database version | POSTGRES_15 |
| Region | europe-west1 |
| Availability | REGIONAL |
| Backups | enabled, startTime: "23:00" |
| Deletion protection | enabled |
| Connectivity | private network + PSC, requireSsl: true |
The netbox database and netbox user are declared in sql/db-list.yaml and sql/db-user.yaml against
the same instance.
Naming inconsistency (cosmetic)
The Secret Manager keys are still namednetbox-alloydb-password / grafana-alloydb-password, and the
Deployment env var is alloydb-password, from an earlier AlloyDB iteration. The backing database is
Cloud SQL. A naming wart, not a functional problem — worth tidying, but out of scope here.Prerequisites: already satisfied
This is the important discovery result. PostgreSQL 15 satisfies every version floor on the path, including the future v4.7 requirement. There is no database workstream.
| Requirement | NetBox 4.6 needs | We have | Status |
|---|---|---|---|
| PostgreSQL | 14+ (15+ from v4.7) | 15 | ✅ satisfied, and future-proof |
| Redis | 5.0+ (6.0+ from v4.7) | 6.2.23 | ✅ satisfied, and future-proof |
| Python | 3.12 – 3.14 | in-image | ✅ n/a — supplied by the container |
| Backups | — | Cloud SQL automated, nightly | ✅ present; take an on-demand one before each hop |
PG floor by release, for reference
| NetBox | PostgreSQL min | Redis min |
|---|---|---|
| 3.5 (current) | 11 | 4.0 |
| 3.6 – 4.1 | 12 | 4.0 |
| 4.2 | 13 | 4.0 |
| 4.3 – 4.6 | 14 | 5.0 |
| 4.7 (future) | 15 | 6.0 |
Upgrade path
The upstream upgrade guide states that NetBox can be upgraded directly to any newer release except when incrementing a major version, which may only be done from the most recent minor release of the prior major. This accommodates the consolidation of schema migrations at major version boundaries.
v3.5.9 therefore cannot go straight to v4.6.7. That is the hard upstream constraint, but it is not the
only one: a direct v3.7.8 → v4.6.7 jump was attempted and failed in production, because the
deployed runtime code ran ahead of the schema it was migrating. The path below therefore steps a single
minor version at a time.
graph LR
A["v3.5.9"] --> B["v3.7.8"]
B --> C["v4.3.7"]
C --> D["v4.4.10"]
D --> E["v4.5.10"]
E --> F["v4.6.7"]| Hop | From → To | Why |
|---|---|---|
| 1 | v3.5.9 → v3.7.8 | Mandatory latest-minor waypoint before crossing the v3→v4 boundary |
| 2 | v3.7.8 → v4.3.7 | Newest release whose code predates core_configrevision.active — see below |
| 3 | v4.3.7 → v4.4.10 | Single minor step |
| 4 | v4.4.10 → v4.5.10 | Single minor step |
| 5 | v4.5.10 → v4.6.7 | Single minor step |
Corrected: intermediate stops ARE required
An earlier revision of this document claimed intermediate stops “only add deployment events without reducing risk”, on the reasoning that the same ordered set of migrations is applied either way.
That reasoning is wrong, and it caused a production outage. The migration list is indeed identical, but the version of the runtime code executing during those migrations is not. Signal handlers and config initialisation run from the deployed image, so a large jump runs new code against an old schema.
Stepping one minor at a time keeps the code and the schema it expects in alignment.
What went wrong going straight to v4.6.7
Attempting v3.7.8 → v4.6.7 failed at users.0005_alter_user_table with a misleading error:
django.db.utils.InternalError: current transaction is aborted,
commands ignored until end of transaction block
That is a consequence, not the cause. The real sequence:
users.0005(a 4.0 migration) callsnetboxuser_ct.delete().- That fires 4.6’s
pre_deletesignal →notify_object_changed→ObjectType.get_for_model. - 4.6’s config initialisation queries
core_configrevision.active. - That column is added by
core.0019_configrevision_active, introduced in 4.4. At this point the schema is still atcore.0010, so the query fails withUndefinedColumn. - The failure aborts the migration’s transaction. NetBox catches the exception and logs
Skipping config initialization (database unavailable), but does not roll back to a savepoint, so the transaction stays poisoned. - The next query in that transaction raises
InFailedSqlTransaction, killing the migration.
All eight DDL statements in that migration are fine — they were verified by replaying them inside a rolled-back transaction. The DDL is not the problem, and the reported error names neither the real query nor the real column.
Why v4.3.7 specifically
Two independent constraints intersect at 4.3:
| Constraint | Effect |
|---|---|
core.0019_configrevision_active arrives in 4.4 | The target must be ≤ 4.3 so its code never queries .active |
DATABASES (plural) was introduced in 4.3 | The target must be ≥ 4.3 for the current config to load |
NetBox rejects defining both forms, so the configuration cannot be made to span the range:
elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'):
raise ImproperlyConfigured("DATABASE and DATABASES may not be set together.")
Targeting 4.0–4.2 would therefore also require reverting configuration.py to the singular form.
v4.3.7 is the only version satisfying both constraints, making it reachable with an image-tag change
alone.
Pre-flighting each hop
The underlying fault is not fixed upstream. In 4.3, 4.4 and 4.6 alike, config initialisation still
catches DatabaseError and returns without rolling back to a savepoint:
except DatabaseError:
# The database may not be available yet (e.g. when running a management command)
logger.warning("Skipping config initialization (database unavailable)")
return
So the hazard is structural, and single-minor stepping is the mitigation rather than a cure. Before each hop, check whether that trap can be sprung:
- List the migrations the hop will apply — diff the
migrations/directories between the two tags. - Find any
RunPythonoperations in that set. Pure schema operations (CreateModel,AlterField,AddField) cannot trigger it. - Check whether those functions fire model signals. Per-object
.save()and.delete()do;bulk_update,bulk_createand queryset.update()do not. The v4.6.7 failure was caused by a.delete()firingpre_delete. - Check the affected tables are non-empty. A
RunPythonthat iterates zero rows cannot fire anything regardless.
Worked example — the v4.3.7 → v4.4.10 hop, 17 migrations:
| Migration | Assessment |
|---|---|
core.0018, extras.0131, users.0011 (concrete_objecttype) | Pure CreateModel/AlterField — no RunPython |
ipam.0083_vlangroup_populate_total_vlan_ids | RunPython, but uses bulk_update (no signals), and there are 0 VLANGroups |
dcim.0214_platform_rebuild | RunPython MPTT rebuild, and there are 0 Platforms |
| Remainder | Schema-only operations |
Nothing in that set fires the signal path that broke the v4.6.7 attempt, so the hop is low risk.
Every hop runs the same procedure:
- Take an on-demand Cloud SQL backup of the
infra-managementinstance. - Bump the image tag in both
netbox-deployment.yamlandnetbox-worker.yaml, and merge. - The container entrypoint applies migrations on startup.
- Verify prefixes and aggregates render correctly and the API responds.
Migrations on an IPAM-only dataset of this size complete in seconds, not hours. The on-demand backup is the rollback mechanism — reverting the image tag alone does not undo a migration.
A failed migration still commits earlier ones
Django applies each migration in its own transaction. When the v4.6.7 attempt failed atusers.0005,
six extras migrations had already committed, leaving the database mid-upgrade and unable to run the
previous image. A failed upgrade is therefore not self-reverting: recovery is either forward to a
compatible version, or a restore from the backup taken in step 1.Breaking changes that apply to us
Filtered to our IPAM-only usage. Full upstream release notes are linked at the bottom.
Must action
| Release | Change | Action |
|---|---|---|
| v4.3 | DATABASES (plural) introduced; singular DATABASE deprecated | Update base/configmap.yaml to the plural form |
| v4.4 | Bulk import/modify/delete became background jobs; housekeeping became a system job | Add an rqworker Deployment — see below |
| v4.2 | Prefix.site FK replaced by a generic scope FK | Data auto-migrates. Verify prefix scoping after hop 2; update any tooling that reads site on a prefix |
Be aware, no action needed
| Release | Change | Impact on us |
|---|---|---|
| v4.2 | Collation-based natural ordering adopted | Default list ordering changes. Cosmetic |
| v4.2 | Django admin UI removed | We do not use it |
| v4.2 | Obsolete nested REST serializers removed | Check any external API consumers |
| v4.5 | v2 API tokens introduced, requiring API_TOKEN_PEPPERS | v1 tokens still work, deprecated in 4.6 and removed in v5.0. Optional for now |
| v4.5 | API token plaintext retrieval removed | Tokens must be captured at creation time |
| v4.6 | v1 API tokens deprecated | Removed in v5.0 — plan token migration separately |
| v3.6 | CustomField.choices → CustomFieldChoiceSets | Auto-migrated. Only relevant if custom fields are defined on prefixes |
Not applicable to us
Listed so the assessment is auditable rather than hand-waved:
- v3.6
Device.device_role→role; napalm fields removed; virtual chassis interface filters - v3.7 Webhook fields → EventRules; L2VPN moved
ipam→vpnapp;ConfigRevisionmoved tocore - v4.0 Legacy reports dropped and converted to custom scripts; GraphQL filter format changed
- v4.1
VirtualMachine.disk/VirtualDisk.sizeunits GB → MB;VLANGroup.min_vid/max_vid→vid_ranges - v4.2
CircuitTermination,Cluster,CircuitGroupAssignmentgeneric FK changes - v4.3
Service.parentgeneric relationship;Contact.group→groupsM2M; staged changes removed - v4.5
cable-terminationsAPI read-only;render_configpermission; platform config context inheritance - All GraphQL syntax changes across v4.0, v4.3 and v4.5 — we do not use GraphQL
Both assumptions confirmed
The “not applicable” list depends on no plugins being installed and no external automation reading the affected endpoints. Both are confirmed — see Pre-flight checks.Container image changes
| netbox-docker | Change | Impact |
|---|---|---|
| 4.0.0 | Nginx Unit replaced by Granian; in-container user unit → netbox | Port stays 8080, so the Service and HTTPRoute are unaffected. Smoke-test static asset delivery |
| 5.0.0 | Baked-in default secrets removed; Ubuntu 26.04 / Python 3.14 | Secrets must be supplied explicitly — we already do. Requires NetBox v4.6+ |
Manifest changes
1. DATABASE → DATABASES (required at hop 2)
In base/configmap.yaml:
DATABASES = {
'default': {
'NAME': 'netbox',
'USER': 'netbox',
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': '10.17.8.5',
'PORT': '5432',
'OPTIONS': {'sslmode': 'require'},
}
}
Keep the singular form for hop 1; introduce the plural form alongside the v4.6.7 image bump.
2. Add an rqworker Deployment (required at hop 2)
From v4.4, bulk import/modify/delete run as background jobs and housekeeping is a system job. Without a worker these silently never complete. This matters directly for sc-10967, which does bulk subnet reconciliation.
The worker runs the same image and tag as the web pod, with the command overridden:
command:
- /opt/netbox/venv/bin/python
- /opt/netbox/netbox/manage.py
- rqworker
The commented-out block already in base/netbox-deployment.yaml is the starting point.
Check the queue first
No worker has ever run, so thetasks Redis database may hold jobs queued since 2023. Inspect the queue
and deliberately drain or purge it before starting the new worker, so it does not execute stale work.3. strategy: Recreate (recommended, one line)
With the default RollingUpdate, a new pod can begin migrating the schema while the old pod is still
serving through code that expects the old schema. Recreate costs a few seconds of downtime on a
single-replica internal tool and removes the overlap entirely.
spec:
strategy:
type: Recreate
4. Probes (recommended)
There are currently no probes, so Kubernetes marks the pod ready while the entrypoint is still migrating
and nothing is listening on :8080. Add a startupProbe generous enough to cover migration time and a
real readinessProbe. Add a livenessProbe only once startup timing is understood, so it cannot kill a
slow initialisation.
If ALLOWED_HOSTS is tightened from ['*'], give the probes an accepted Host header rather than
reopening the wildcard.
5. Optional tidy-ups
Not blockers, but worth folding in while the manifests are open:
ALLOWED_HOSTS = ['*']→['netbox.infra.titanbay.com'].SKIP_SUPERUSER="false"on the long-running Deployment → verify an admin exists, set"true", and drop theSUPERUSER_*env vars.SUPERUSER_PASSWORDreuses the same secret key asSECRET_KEY(secret-keyinnetbox-credentials). Separate and rotate them independently.API_TOKEN_PEPPERS— not required while v1 tokens work, but required before v5.0. Must be independent ofSECRET_KEY, at least 50 characters, sourced from Secret Manager.securityContext—runAsNonRoot, dropped capabilities. Determine the image’s actualnetboxUID/GID first; it changed fromunitin netbox-docker 4.0.0.
Not needed at our scale
Recorded so these are consciously declined rather than overlooked:
- Splitting Redis into separate task and cache instances. Correct at scale, unnecessary here.
- PersistentVolumes for
media/,scripts/,reports/. IPAM-only usage means there should be nothing in them — confirm once during pre-flight, then accept the ephemerality. - A production-clone rehearsal. Justified for a large dataset; disproportionate for this one. The on-demand backup covers it.
- Gated ArgoCD sync waves with a dedicated migration Job. The entrypoint migration on a small dataset is fine given a backup taken immediately beforehand.
Pre-flight checks
These were established once and hold for the whole campaign.
| # | Check | Status |
|---|---|---|
| 1 | No plugins installed | ✅ settings.PLUGINS == [] confirmed at runtime; stock image, no custom build, no Kustomize image override |
| 2 | No external API consumers | ✅ NetBox is read and edited through the UI only |
| 3 | media/, scripts/, reports/ empty | ✅ Only shipped stubs — two .gitignore, two __init__.py |
| 4 | tasks Redis queue drained | ✅ Empty — no rq:* keys, all queue depths 0 |
| 5 | On-demand Cloud SQL backup | ✅ Taken before each hop |
Check 1 is the significant one: an incompatible plugin can stop Django starting before migrations run.
Check 2 removes an entire risk category. Every v4 API-facing breaking change — removed filters, obsolete
nested serializers, the GraphQL syntax changes, and the /api/vpn/ L2VPN move — only affects API callers.
It also makes API_TOKEN_PEPPERS optional for now, since tokens are only used by people in the UI.
Per-hop, the migration-plan assessment described under Pre-flighting each hop must also be repeated.
Backup checkpoints
Take a fresh on-demand backup before every hop. A backup only restores the schema as it was when
taken, so its value decays with each hop: the original pre-upgrade backup now restores a v3.7.8 schema,
which is over a hundred migrations behind.
This is not theoretical. The failed v4.6.7 attempt committed six migrations before dying, which left the
database unable to run the previous image — the backup was the only route back.
Progress
| Hop | Change | Status |
|---|---|---|
| 1 | v3.5.9 → v3.7.8, strategy: Recreate | ✅ Merged and verified |
| — | PGSSLCERT + startupProbe/readinessProbe | ✅ Merged — see Probes |
| 2 | v3.7.8 → v4.6.7, DATABASES, rqworker | ❌ Failed — see What went wrong |
| 3 | v4.6.7 → v4.3.7 recovery step | ✅ Merged and verified |
| 4 | v4.3.7 → v4.4.10, plus sync-wave ordering | ✅ Merged and verified |
| 5 | v4.4.10 → v4.5.10 | ✅ Merged and verified |
| 6 | v4.5.10 → v4.6.7 | ✅ Merged and verified |
Final state verified: 791 migrations applied, migrate --check clean, /login/ returning 200, and
5 Aggregates / 14 Prefixes / 3 Sites intact with all 14 prefixes still scoped to dcim.site. The v4.6
feature tables (dcim_rackgroup, dcim_cablebundle, virtualization_virtualmachinetype) are present.
Six deployments were needed rather than the two originally scoped. The extra four are a direct consequence of the analysis error described above.
Known gaps after v4.6.7
These are deliberately out of scope for the upgrade itself and should be tracked separately:
| Item | Why it matters |
|---|---|
API_TOKEN_PEPPERS not set | Required to create v2 API tokens. v1 tokens are deprecated in 4.6 and removed in v5.0. There are currently 0 tokens, so nothing is broken today |
SECURE_PROXY_SSL_HEADER not set | Envoy terminates TLS, so csrftoken/sessionid are issued without Secure. Mitigated by the blanket HTTP→HTTPS 301. Confirm Envoy sets X-Forwarded-Proto before setting this |
ALLOWED_HOSTS = ['*'] | Should be the real hostname; note the probes would then need an explicit Host header |
Superuser password shares SECRET_KEY | SUPERUSER_PASSWORD reads the same secret key as SECRET_KEY; these should be separate and rotated independently |
| Resolved — Redis is now a StatefulSet with a 10Gi PersistentVolume and AOF enabled, so the task queue survives a restart | |
| Container memory limit is 1Gi | Tight enough that running a second Python process inside the web pod will OOM-kill it. Use the worker pod for diagnostics |
| v4.7 will require PostgreSQL 15+ and Redis 6+ | We are already on PG15; Redis is 6.2.23, so both are satisfied |
Probes were deliberately not bundled into hop 1, to keep a not-ready pod attributable to the upgrade
rather than the probe configuration. In hindsight that was the wrong trade: it cost detection on the
PGSSLCERT failure, where the pod reported 1/1 Running throughout a total outage.