Pinning Metabase in Kubernetes: Stop Using Latest
June 12, 2026
The case for pinning OSS images
latest is a lie. It’s a mutable pointer that can change under you any time the maintainer pushes a new build. On a pod restart - during a node drain, a rollout, a crash recovery - Kubernetes pulls the image again, gets whatever latest points to now, and your workload is running a version you never tested.
For stateless apps this is tolerable. For stateful apps - anything that runs schema migrations on startup - it’s a problem.
Liquibase, Flyway, and similar tools modify your database on startup. They’re designed to be forward-only. A migration that creates a column, renames a table, or adds an index doesn’t have an automatic undo. If the new version’s migration fails halfway through, you can end up in a state where the old image sees “already migrated” and the new image sees “migration failed” - and neither can start cleanly.
The rule is simple: pin every stateful OSS workload to an explicit version tag.
What happened
We were running Metabase in Kubernetes for internal analytics. The image was not pinned to an explicit patch version. A routine upgrade to v0.63.2 went out, the pod restarted, and it never came back up.
The pod kept crashlooping. Logs showed Liquibase failing during the migration phase on startup. Metabase couldn’t complete its migration checks, threw an exception, and exited - over and over.
We rolled back the image to v0.63.1.2. The pod still wouldn’t start. The DATABASECHANGELOG table - Liquibase’s record of which migrations have run - was now in a partially inconsistent state from the failed v0.63.2 migration attempt. The old image couldn’t reconcile it either.
We had to manually inspect and repair the table before either version could start cleanly.
Why it failed: Liquibase internals
Metabase uses Liquibase to manage its database schema. Liquibase tracks every migration it has run in a DATABASECHANGELOG table, keyed by (id, author, filename).
In Metabase’s migrations/063/ directory, most files use their physical path as the filename in DATABASECHANGELOG. But two files - 20260604_curated_search_entries.yaml and 20260623_osi_ai_context.yaml - have a logicalFilePath attribute set to migrations/063_update_migrations.yaml. This tells Liquibase to record them under that consolidated filename instead of their physical path.
v0.63.2 introduced a consolidation step: a bulk UPDATE to rename all DATABASECHANGELOG entries matching filename LIKE 'migrations/063/%' to the consolidated path. The problem: two changesets, ghy_3747 and ghy_3933, share the same (id, author) pair. When the bulk UPDATE tried to move both to the same consolidated filename, it hit a unique constraint violation. Liquibase aborted. Metabase couldn’t start.
This is a bug in Metabase’s migration authoring - duplicate (id, author) pairs that were never caught because the consolidation code path was never triggered until v0.63.2. It’s also the kind of thing you cannot predict by reading release notes.
The fix: pin and repair
Step 1: repair DATABASECHANGELOG.
We needed a stable mixed state that both satisfies Liquibase’s lookup logic and avoids the constraint violation on every future startup:
- 4 entries with
filename = 'migrations/063_update_migrations.yaml'- the two files that declarelogicalFilePath(Liquibase looks them up by consolidated name) - 59 entries with individual
migrations/063/...filenames - everything else - Both
ghy_3747andghy_3933present as individual-path entries
With this layout, on every restart: the consolidation UPDATE silently fails (constraint violation on the duplicates), Liquibase falls back to reading individual JAR paths, finds all migrations already recorded, sees 0 unrun migrations, and Metabase starts cleanly.
Step 2: pin the image.
image: metabase/metabase:v0.63.1.2
imagePullPolicy: IfNotPresent
IfNotPresent means: if the image is already cached on the node, don’t pull again. Combined with an explicit version tag, this gives you two layers of protection against silent upgrades. If you want even stronger guarantees, pin by digest (image: metabase/metabase@sha256:...) - a digest is immutable, a tag is not.
Step 3: add a startupProbe with enough headroom.
Metabase can take a while to run migrations on first start or after an upgrade. Without a startupProbe, Kubernetes’s default liveness/readiness probes can kill the pod before migrations finish, creating a crashloop that looks like an application bug.
startupProbe:
httpGet:
path: /api/health
port: 3000
failureThreshold: 60
periodSeconds: 5
This gives Metabase 5 minutes (60 × 5s) to pass its health check before Kubernetes declares it failed. After startup succeeds, normal liveness and readiness probes take over.
How to upgrade safely
Now that we’re pinned, upgrades are deliberate:
-
Check the release notes for any mention of schema migrations, Liquibase changes, or breaking changes to the application database. For Metabase, pay attention to any new files in
migrations/and whether they uselogicalFilePath. -
Test in staging against a copy of prod data. Restore a recent prod DB snapshot into staging, update the image tag, and let it run. Watch the startup logs for Liquibase errors.
-
Snapshot prod before upgrading. If the migration fails and leaves
DATABASECHANGELOGin an inconsistent state, you need to restore - not just roll back the image tag. -
Deploy and watch startup logs live. Don’t wait for the health check to fail.
kubectl logs -fthe pod from the moment it starts and watch Liquibase’s output. -
If it fails: image tag rollback + DB restore. Rolling back only the image tag is not enough if migrations partially ran. Restore the DB snapshot and then roll back the image.
When it breaks
Pod crashlooping after an image update.
Check the logs immediately - if you see Liquibase errors, stop restarting the pod. Each restart may attempt (and partially apply) more migrations. Roll back the image tag first, then inspect DATABASECHANGELOG before doing anything else.
Rolled back image tag but pod still won’t start.
The migration table is inconsistent. The old image sees entries it doesn’t recognise or can’t reconcile. You need to either restore a DB snapshot (cleanest) or manually repair DATABASECHANGELOG to match what the old image expects.
Startup probe keeps failing, pod gets killed before it’s ready.
Migrations are taking longer than the probe allows. Increase failureThreshold on the startupProbe. Don’t increase the liveness probe timeout - that controls the steady-state, not startup.
Unsure which image version is actually running.
kubectl get pod <metabase-pod> -o jsonpath='{.spec.containers[0].image}'
If you’re seeing a digest instead of a tag, cross-reference it against the registry to confirm the version.
imagePullPolicy: Always with a pinned tag.
Even with an explicit version tag, Always tells Kubernetes to check the registry on every start. If the tag is mutable (the maintainer re-pushed to the same tag), you can still get a different image. Use IfNotPresent or pin by digest.
