TeslaMate v3.1.0 dropped this week, and along with the usual app improvements the upstream docker-compose.yml quietly bumped the recommended Postgres image from 17 to postgres:18-trixie. That’s a major version jump, which means it’s a dump-and-restore migration on the storage side. PG 18 will refuse to start against a PG 17 data directory.
This is a walkthrough of how I did it on my homelab cluster: RKE2 + Longhorn storage, plain Kubernetes manifests, GitOps via Argo CD. About three minutes of downtime, no data loss, all infrastructure-as-code committed at the end. If you’re running TeslaMate in Kubernetes, this should map directly. If you’re on Docker Compose, the official TeslaMate docs already cover that case. This post is for the kube bois.
Minor Postgres upgrades (17.9 → 17.10) are just an image swap (handled by renovate on my end): the new container starts on the existing data directory and goes about its day. Major upgrades (17 → 18) change the on-disk page format, so the new binary refuses to attach to the old PGDATA.
Your two options are:
pg_upgrade: in-place binary upgrade. Faster, but it requires both old and new binaries on the same host and isn’t practical to script inside a stock Postgres container.pg_dump/pg_restore: dump the logical database, stand up an empty PG 18 instance, restore the dump. Slower for huge databases, but cleaner, gives you a portable backup file as a side effect, and works fine for typical TeslaMate sizes. Mine was 243MB and restore took under a minute.
I went with dump/restore. TeslaMate’s own upstream docs recommend the same approach.
Before touching anything, gather the facts. You want to know exactly what you’re about to cook.
# Confirm the current Postgres image
kubectl -n teslamate get deploy teslamate-db \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# Database size (predicts restore time)
kubectl -n teslamate exec deploy/teslamate-db -- \
psql -U teslamate -d teslamate \
-c "SELECT pg_size_pretty(pg_database_size('teslamate'));"
# PVC details
kubectl -n teslamate get pvc teslamate-db
# Underlying PV and its reclaim policy
kubectl get pv $(kubectl -n teslamate get pvc teslamate-db -o jsonpath='{.spec.volumeName}') \
-o jsonpath='Reclaim={.spec.persistentVolumeReclaimPolicy}{"\n"}'
If your PV reclaim policy is Delete (Longhorn’s default), deleting the PVC will destroy the underlying volume. That’s fine, since we’re replacing it, but we’ll patch it to Retain first so we have a rollback path.
Also: if you’re running Argo CD or Flux, disable sync on the TeslaMate application before you start. Otherwise GitOps will fight you when you scale things down or delete the PVC. Re-enable it at the end. Argo and I fight all the time. Argo always wins.
The custom format (-Fc) is the right call here: it’s compressed, fast to restore in parallel if you ever need it, and works across Postgres major versions.
DB_POD=$(kubectl -n teslamate get pod -l app=teslamate-db -o jsonpath='{.items[0].metadata.name}')
# Dump inside the pod
kubectl -n teslamate exec "$DB_POD" -- \
pg_dump -U teslamate -Fc -f /tmp/teslamate-pg17.dump teslamate
# Copy it back to your workstation
mkdir -p ~/teslamate-backups
kubectl -n teslamate cp \
"$DB_POD:/tmp/teslamate-pg17.dump" \
~/teslamate-backups/teslamate-pg17.dump
# Sanity check
file ~/teslamate-backups/teslamate-pg17.dump
shasum -a 256 ~/teslamate-backups/teslamate-pg17.dump
You should see PostgreSQL custom database dump - v1.16-0 or similar. Record the sha256 somewhere. It’s your “the file on disk is the file I made” proof.
This is the cheap insurance policy. With Retain, deleting the PVC orphans the underlying PV instead of destroying it. If the migration goes sideways you can manually rebind a new PVC to the old PV and roll back to PG 17.
PV_NAME=$(kubectl -n teslamate get pvc teslamate-db -o jsonpath='{.spec.volumeName}')
kubectl patch pv "$PV_NAME" \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
kubectl get pv "$PV_NAME" -o jsonpath='{.spec.persistentVolumeReclaimPolicy}{"\n"}'
# Retain
Stop the writers (TeslaMate app and Grafana) before stopping the database. This avoids a flurry of failed connections in the logs and ensures no in-flight transactions get half-written when the DB goes away.
# Stop writers first
kubectl -n teslamate scale deploy teslamate teslamate-grafana --replicas=0
# Then the database
kubectl -n teslamate scale deploy teslamate-db --replicas=0
kubectl -n teslamate wait --for=delete pod -l app=teslamate-db --timeout=60s
In your teslamate-db-deployment.yml, change the image tag. Keep your custom args for shared_buffers and friends. Postgres 18 accepts the same configuration parameters.
containers:
- name: postgres
- image: postgres:17.10
+ image: postgres:18-trixie
imagePullPolicy: Always
args:
- "-c"
- "shared_buffers=96MB"
# … your other tunings stay
This is the destructive step. Make sure you have the backup from Step 1.
kubectl -n teslamate delete pvc teslamate-db --wait=true --timeout=60s
# The PV should now be in Released state, still Retained
kubectl get pv "$PV_NAME" -o jsonpath='{.status.phase}{"\n"}'
# Released
Apply your PVC manifest to provision a brand-new, empty 5Gi volume:
kubectl apply -f teslamate-db-pvc.yml
kubectl apply -f teslamate-db-deployment.yml
kubectl -n teslamate scale deploy teslamate-db --replicas=1
kubectl -n teslamate wait --for=condition=Ready pod -l app=teslamate-db --timeout=180s
On first boot, the official Postgres image runs initdb against the empty volume, applies your environment variables (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB), and starts a clean PG 18 instance. Verify:
NEW_POD=$(kubectl -n teslamate get pod -l app=teslamate-db -o jsonpath='{.items[0].metadata.name}')
kubectl -n teslamate exec "$NEW_POD" -- \
psql -U teslamate -d teslamate -c "SELECT version();"
# PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1) on x86_64-pc-linux-gnu, …
kubectl -n teslamate cp \
~/teslamate-backups/teslamate-pg17.dump \
"$NEW_POD:/tmp/teslamate-pg17.dump"
kubectl -n teslamate exec "$NEW_POD" -- \
pg_restore -U teslamate -d teslamate \
--no-owner --no-privileges \
/tmp/teslamate-pg17.dump
The --no-owner and --no-privileges flags keep pg_restore from trying to reassign ownership to roles that may not exist in the fresh database. Then verify your data is actually there:
kubectl -n teslamate exec "$NEW_POD" -- \
psql -U teslamate -d teslamate \
-c "\dt" \
-c "SELECT count(*), max(date) FROM positions;" \
-c "SELECT count(*) FROM drives;" \
-c "SELECT count(*) FROM charging_processes;"
You want 12 tables in the public schema, your familiar row counts, and a recent timestamp on the most recent position. If the numbers match what you saw before the migration, you’re good. Run ANALYZE so the query planner has fresh stats on the new instance:
kubectl -n teslamate exec "$NEW_POD" -- \
psql -U teslamate -d teslamate -c "ANALYZE;"
kubectl -n teslamate scale deploy teslamate teslamate-grafana --replicas=1
kubectl -n teslamate wait --for=condition=Ready pod -l app=teslamate --timeout=120s
kubectl -n teslamate wait --for=condition=Ready pod -l app=teslamate-grafana --timeout=120s
Watch the TeslaMate logs. You’re looking for two specific messages:
kubectl -n teslamate logs deploy/teslamate --tail=50
TeslaMate explicitly checks PG compatibility on startup. The two lines that tell you everything went right:
[info] Migrations already up
[info] PostgreSQL version 18.4 is compatible (18.x series).
Migrations already up means your schema came across intact. The compatibility line means TeslaMate is happy with PG 18.
Get the manifest change into git so your cluster state matches your repo state:
git add default-cluster/teslamate/teslamate-db-deployment.yml
git commit -m "teslamate: bump postgres to 18-trixie"
git push
Then re-enable sync on the Argo application. Because what’s deployed already matches what’s in git, the first sync should be a no-op with no drift and no surprises.
Because you set the PV reclaim policy to Retain, deleting the PVC didn’t clean anything up on the storage layer. You have an orphaned PV and an orphaned Longhorn volume sitting around. Once you’re confident the migration was successful, delete them:
# Delete the orphaned PV (it's in Released state, claimed by nothing)
kubectl delete pv "$PV_NAME"
# Longhorn won't auto-delete the underlying volume because of the Retain policy.
# You have to delete the CRD explicitly.
kubectl -n longhorn-system delete volumes.longhorn.io "$PV_NAME"
Also clean up the dump files when you’re satisfied:
kubectl -n teslamate exec "$NEW_POD" -- rm -f /tmp/teslamate-pg17.dump
rm ~/teslamate-backups/teslamate-pg17.dump
I’d wait 24–48 hours of normal operation before deleting these. If something subtle is wrong with the restored database, you’ll usually find out within a day, and the dump is your fastest path back.
Argo CD will undo your work if you forget to disable it. Scaling a deployment to 0 looks like drift from the desired state. Suspend the application or set
syncPolicy: manualfor the duration of the maintenance window.The Longhorn volume CRD outlives the PV when reclaim is set to Retain. You have to delete it from
longhorn-systemnamespace separately. It’s easy to forget and end up with phantom storage usage.Recurring snapshot jobs can prune fresh snapshots. If you try to take a manual Longhorn snapshot via the CRD and find it disappears, check whether you have a recurring snapshot cleanup job running. It may consider unlabeled manual snapshots eligible for deletion.
pg_dumpis more reliable as a primary backup anyway.PG 18’s new async I/O is worth tuning later. For Longhorn-backed databases (or any network-attached storage), setting
io_method=workerorio_method=io_uringand bumpingeffective_io_concurrencycan meaningfully speed up dashboard queries. Not urgent. Only worth chasing if you actually notice slow queries.Restore size will differ slightly from the original. Mine went from 243MB to 234MB after restore. That’s normal: fresh indexes, no accumulated bloat, slightly different storage layout in PG 18. Not a bug.
A major Postgres upgrade in Kubernetes is just five mechanical things in the right order:
Dump the old database, copy the file off-cluster.
Patch the PV to
Retainfor rollback safety.Scale down, delete the old PVC, bump the image, apply a fresh PVC.
Restore the dump into the new empty database.
Scale back up, verify, commit the manifest change, re-enable GitOps.
Total downtime on my 243MB TeslaMate database: about three minutes.
Impressive that I did not cook my data. Claude helped.
Cheers,
Joe

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.