This is the story of one container from my homelab security audit: an old PocketBase instance that had been quietly serving a small app for months, until a vulnerability scan put it at the top of the list.

It covers how the problem was discovered, how I inspected a live database without risking it, and the upgrade from PocketBase 0.18.1 to 0.40.5 that took it from 5 critical findings to zero.

If you are new to PocketBase, start with my PocketBase review. For the full audit workflow this came out of, see the homelab security hardening guide.

How It Was Discovered

The audit started with a simple question: what is actually reachable from the internet?

I listed every hostname published through my Cloudflare Tunnel and the Docker origin behind each one. The tunnel is remotely managed, so the current route list came from the connector’s own logs:

docker logs cloudflared 2>&1 | grep 'Updated to new configuration'

PocketBase was one of those origins, published as pocketbase:8080.

Then I scanned every exposed image with Trivy , run as a throwaway container so nothing was installed permanently and no service was restarted:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /tmp/trivy-cache:/root/.cache \
  aquasec/trivy:latest image \
  --scanners vuln \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  pocketbase:202610

--ignore-unfixed hides findings with no known fix. It does not hide anything you could actually patch.

The result:

Service Critical High
PocketBase 5 84

The cause was not mysterious. The container ran PocketBase 0.18.1, many releases behind, on an Alpine base pinned to an old snapshot. Both the application binary and the OS packages were out of date.

Know What You Are Protecting

Before upgrading an unfamiliar service, I wanted to know what was in it. A big version jump on an empty instance and on one holding real users are very different risks.

Reading a Live SQLite Database Safely

PocketBase stores everything in SQLite under pb_data/. The service was running, so the inspection had to be strictly read-only.

My first attempt with a plain read-only open failed: SQLite still tried to create a journal file and was rejected. The fix was SQLite’s immutable URI mode, which guarantees no write attempt at all:

DB='file:/srv/pocketbase/pb_data/data.db?mode=ro&immutable=1'

sqlite3 -readonly "$DB" '.tables'
sqlite3 -readonly -header -column "$DB" \
  'SELECT id,name,type,system,created,updated FROM _collections ORDER BY name;'

Then for each collection, only counts and field definitions:

sqlite3 -readonly -header -column "$DB" 'SELECT count(*) AS records FROM "collection_name";'
sqlite3 -readonly -header -column "$DB" 'PRAGMA table_info("collection_name");'

I deliberately read metadata, counts, and aggregates only: no names, emails, password hashes, tokens, or record bodies went into my notes. Inspecting a database is not a reason to copy personal data around.

immutable=1 tells SQLite the file will not change while it reads. On a busy database that can give a slightly stale view, which is fine for counting collections but not for anything transactional.

What Was There

The instance turned out to be small but real:

  • a couple of dozen completed website audits
  • a few dozen user accounts, almost all unverified
  • a handful of leads and repository records
  • one active subscription
  • several empty collections from features never used

The whole pb_data directory was under 1 MB, plus about 76 KB of migrations. Small enough to back up in seconds, and real enough that losing it would matter.

Back Up Before Touching Anything

A dated backup went next to the service, containing everything needed to restore both the data and the old container:

backup=/srv/pocketbase/backup-before-0.40.5-20261027
mkdir -p "$backup"
cp -a /srv/pocketbase/pb_data "$backup/"
cp -a /srv/pocketbase/pb_migrations "$backup/"
cp -a /srv/pocketbase/build/Dockerfile "$backup/Dockerfile"
docker image inspect pocketbase:202610 --format '{{.Id}}' > "$backup/current-image-id.txt"
File Why it matters
pb_data/ The SQLite databases: all records, users, and settings
pb_migrations/ Schema migrations; restoring data without them can leave the schema inconsistent
Dockerfile The exact old build recipe
current-image-id.txt Lets me run the old image again if it is still on the host

The backup was about 1.1 MB. Nothing in the live directories was deleted or modified.

The Upgrade

Rebuild Without Touching Production

The latest official release was 0.40.5. I updated the Dockerfile, and also moved the base from a pinned Alpine patch snapshot to the 3.22 stream with an explicit package refresh:

FROM alpine:3.22
ARG PB_VERSION=0.40.5

RUN apk add --no-cache --upgrade \
    unzip \
    ca-certificates

Then I built a new image without recreating the running container:

docker compose -f docker-compose.yml build --no-cache pocketbase

The First Rebuild Was Not Clean

Scanning the candidate before deploying caught a surprise: the new PocketBase binary was in, but the critical Alpine findings were still there. The base image was still the old pinned snapshot, so upgrading the application had not touched the OS layer.

Switching to alpine:3.22 and rebuilding pulled in Alpine 3.22.7 with current packages. The rescan:

Image Critical High
Before (0.18.1, old Alpine) 5 84
First rebuild (0.40.5, old Alpine) still critical —
Final (0.40.5, Alpine 3.22.7) 0 2

That is the most useful lesson from this container: upgrading the app and refreshing the base are two separate jobs. Scan the candidate, not just the one you replaced.

Deploy and Verify

Only after a clean candidate scan did I recreate the live service, with the same bind mounts:

docker compose -f docker-compose.yml up -d pocketbase

Checks afterwards:

PocketBase version 0.40.5

GET /api/health -> HTTP 200
{"message":"API is healthy.","code":200,"data":{}}

The collections and records were all still present, because pb_data and pb_migrations stayed on the host and were simply mounted into the new container.

A jump from 0.18 to 0.40 crosses major internal changes in PocketBase, including the large 0.23 refactor. PocketBase migrates the database on startup, which is exactly why the backup had to exist before the new version ever saw the data. If you have a frontend talking to PocketBase, read the changelog and test your client and SDK version too, not just the health endpoint.

The Two Findings That Remain

The two remaining High findings are inside the official PocketBase binary:

  • golang.org/x/net v0.59.0, fixed in v0.60.0
  • the Go standard library 1.27.2, fixed in 1.27.3

They are compiled into the binary, so no Alpine update can touch them. The options were to build PocketBase from source with newer dependencies, or wait for an official release built with them.

I chose to wait for upstream. A hand-patched build of a backend that holds user accounts is its own maintenance burden and risk. These are High, not Critical, and they are now tracked in my security notes for the next release.

A vulnerability finding is not proof of compromise. It identifies a component that needs a decision, and “accept until the next upstream release” is a valid, recorded decision.

Rollback Plan

Written down before deploying, not after something breaks:

  1. Stop or recreate only the PocketBase service.
  2. If the new version changed the database incompatibly, restore pb_data and pb_migrations from the dated backup.
  3. Rebuild the old 0.18.1 image from the saved Dockerfile, or run the preserved image ID if it is still on the host.
  4. Recreate the service with the original mounts.
  5. Check /api/health and compare collection counts with the pre-upgrade notes.

The old image stays on the host until the new version has run long enough to trust.

Lessons

  • Scanning finds what neglect hides. This service worked perfectly, which is exactly why nobody had upgraded it.
  • Inspect before upgrading an unfamiliar service, and only read what you need.
  • Use mode=ro&immutable=1 to look inside a live SQLite database without any write attempt.
  • Back up pb_data and pb_migrations, plus the old Dockerfile and image ID.
  • Refresh the base image, not just the application. A pinned base snapshot quietly keeps old packages.
  • Scan the candidate before deploying, not only the image being replaced.
  • A clean OS layer does not mean a clean binary. Embedded Go dependencies need an upstream release.
  • Record the remaining findings and the reason they are accepted.

FAQ

Conclusion

PocketBase is a great self-hosted backend because it is so easy to run, and that is how mine ended up many releases behind without anyone noticing. A scan of the internet-facing containers, a careful read-only look at the data, a complete backup, and a candidate image scanned before deployment turned 5 critical findings into zero in one maintenance window.

The same pattern of inventory, inspect, back up, rebuild, scan, deploy, and verify applies to every container in the homelab security hardening guide. Another example is the Nextcloud upgrade for PHP2Shell.