============================================================
Status: PLAN v1.0, 2026-10-14 — SeaweedFS chosen (Matthew, 2026-10-14). This page was the scoping note
that found the problem; it is now the plan the build is instructed from. As built (2026-10-15): WO-M1/M2/M3/M5
built and verified; WO-M6 primary decommission done (image retained for rollback); WO-M4 the dental Client VPS
swapped to SeaweedFS and verified end-to-end (MinIO stopped and retained); WO-M7 mostly done (DSPT-log closing
line outstanding). The
Client VPS Patching work (WO-D) was parked behind this deliberately
— the dental Client VPS's own copy is one of the images that quarterly client-tier refresh covers, so the swap
settles that list too — and is now un-parked (2026-10-15).
Mitigation declined (2026-10-13): preserving the frozen image as a tar (and adding the service to the cold
standby) was offered and declined — effort goes to the replacement. Recorded in the
Patching Log as an accepted risk, not an oversight.
⚠️ MinIO decommissioned on the primary 2026-10-15 (WO-M6); the frozen image is retained read-only. The local
minio/minio image is still the only copy in existence, cannot be pulled from any registry, and is kept
deliberately for rollback — do not prune it until its removal is explicitly confirmed. Nothing writes to MinIO
any more; SeaweedFS serves the shared store.
🔒 PROTECTED — the F4 ruling, 2026-10-17 (a rule, not a memory). The image (minio/minio:latest,
sha256:14cea493d9a3…, 241 MB) is retagged veripath-keep/minio:14cea493d9a3-rollback and a stopped
keep-container veripath-minio-rollback-keep references that tag, so docker image prune -a cannot remove it as
"unused". Any cleanup must exclude the veripath-keep/minio tag and that container, and
docker system prune -a --volumes stays banned. Removal becomes Matthew's recorded decision with a date, never
a default. (Verified on the primary: no MinIO container was running, so the keep-container is what holds the image.)
The dental Client VPS (client-dental-vps) ran its own MinIO, serving the same bucket names and holding the
radiographs for test-client-dental; the app engine reaches it over the tunnel forward on port 5438. It is the same
withdrawn community image — and this page's 2026-10-13 note that it "has no management path from the primary" was
wrong: we hold root SSH to it, and it was swapped in place on 2026-10-15 (WO-M4). The two earlier answers on that
point, and how the error arose, are on the
WO-M4 runbook.
Both copies are now on SeaweedFS, so the replacement covers the estate; the client-side leg is still the harder
one to operate, which is what
Client VPS Patching — Managed Service exists to fix (the agent,
signed jobs, and the access-mode switch).
MinIO has withdrawn the community image. Verified 2026-10-13: Docker Hub's repository API returns 404 for
minio/minio, docker manifest inspect fails for minio/minio:latest and for the quay path, and our local
copy is the only one left. It is licensing-driven — the open-source distribution was withdrawn in favour of
a commercial one. Our container runs perfectly well today (digest-pinned, data on bind mounts, unless-stopped),
so this is not an outage risk; it is a recoverability risk: if that local image is ever lost — a prune,
a host rebuild, or a restore onto the cold standby, whose compose contains no MinIO service at all — it can
never be re-obtained, and it will receive no further upstream security fixes.
| Check | Result |
|---|---|
| Buckets in storage | dental-radiographs, dental-radiographs-test, patient-images |
| Code that talks to it | dental/services/minio_client.py (the single access module), dicom_ingest.py, dicom_queue.py, plus views.py / webhooks.py / api/views.py |
| App config referencing it | only dental_booking_app/config/secrets/env.production |
| Everything else | no reference in the GP app, the pharmacy app, the patient portal, or LiveKit (its egress has no S3 backend configured) |
| Tenant rows | only test-client-dental carries minio_bucket (dental-radiographs) |
Footnote: the patient-images bucket is referenced by nothing in any repo — an orphan. Decide in the
migration session whether it holds anything worth carrying; do not delete it on assumption.
So the answer to "is it just the dental app, for DICOM x-rays?" is yes — the pipeline is
Orthanc (PACS) → the dental app's DICOM ingest → object storage, and nothing else in the estate depends on it.
The app reaches storage through one module with a small surface — ensure_bucket, upload_bytes,
upload_file, presigned_url, get_object_bytes, and config resolution. Everything else calls that. So the
work is: stand up an S3-compatible service, repoint that module and its config, copy the buckets.
Chosen: SeaweedFS, self-hosted, S3 gateway. Repository:
github.com/seaweedfs/seaweedfs. Considered and not chosen, recorded
so a later reader sees what was weighed rather than only what won: Garage (its own compatibility table
implements presigned URLs, CreateBucket and SigV4, so the technical fit was there), Ceph RGW (heaviest to
operate on a four-core host), and a supported MinIO distribution under the commercial licence (cost, and no
image available to us today).
The image, pinned — corrected 2026-10-14. chrislusf/seaweedfs:4.48 (latest release 2026-09-28).
Erratum: the first version of this page named sha256:eb1b8173… as the amd64 digest; it is the
linux/arm/v7 manifest. Re-checked independently and corrected rather than quietly fixed, because a pin is
only as good as the platform it names:
| Reference | Digest |
|---|---|
| Multi-arch index (the pin actually written, house style) | sha256:4e61d15fd35994cb1e43e1e553dff106794841fd9a99ade2fc8c8bfce4d7872d |
| linux/amd64 manifest | sha256:aba492e2a4e4c90bff795745e8e660affa1f09e7650f5981bd7bccd1a06cd931 |
| linux/arm/v7 manifest (what this page first named) | sha256:eb1b81730aaed7369ee0a9535c039436565be67d58030ec981a2401e8620802b |
not exist — and there is no :latest tag, so the release tag is explicit. The upstream wiki documents
weed server -s3 -s3.config <file> as the single-container shape: master, volume, filer and S3 gateway in one
process, S3 on 8333 by default, with -iam.config only needed for advanced IAM (we do not).
Verified fit against our own call surface. The app speaks S3 through one module
(dental/services/minio_client.py) and makes exactly seven calls: bucket_exists, make_bucket, put_object,
fput_object, stat_object, get_object, presigned_get_object. SeaweedFS's S3 API page marks
CreateBucket, HeadBucket, HeadObject, GetObject, PutObject and Presigned URLs all supported — every call we
make — plus multipart uploads, object versioning and SSE-S3/SSE-C/SSE-KMS. That is the vendor's table, not our
proof, which is why WO-M1's acceptance is the app's own calls against a real bucket and a presigned URL
fetched over HTTP and byte-compared.
One thing to settle before cutover, not assume. The app's endpoint is a single-label service name on port
9000, TLS off (shape checked; values withheld). How that name resolves from the apps host is not defined
in the dental compose on that host (checked), so it comes from that host's environment — find it before cutting
over, and decide whether to publish SeaweedFS on 9000 (no consumer change at all) or on 8333 and update the
environment and the tenant row.
OpenCode ran a read-only survey before copying anything. It overturned four things written above, and one of them
was my error — recorded here rather than quietly edited, because the pattern matters: the plan was written from
the outside, and the estate disagreed with it.
| What this page said | What the estate actually is |
|---|---|
"amd64 digest sha256:eb1b8173…" |
wrong platform — that is linux/arm/v7. amd64 is sha256:aba492e2…; the pin written is the multi-arch index sha256:4e61d15f…. Corrected above. |
| "whether the port can be moved is undocumented — do not assume a flag" | -s3.port exists; the gateway binds 9000, so the app-side change is the name only, not the port. (Asking the estate beat assuming either way.) |
"set -dir explicitly; the default is not durable" |
confirmed — the default is /tmp. -dir=/data with the bind mount at /data is what was built. |
WO-M3: "repoint the tenant row for test-client-dental" |
wrong, and it would have reversed SIAAS. MINIO_ENDPOINT=minio:9000 does not resolve at all on the apps host; the tenant's only live path is its own VPS (172.18.0.1:5438 → the client's store, via tun-vps1-dental-minio.service). The primary's store is the shared/dev one (67 objects, demo_*, newest 2026-08-12); the live store is the dental Client VPS (123 objects, newest 2026-09-24). migrate_tenant_radiographs.py documents the model outright: SIAAS — shared store on the platform, each tenant's images on the tenant's own VPS. |
✅ RULED (Hermes, 2026-10-14) — option (a): keep SIAAS. The primary's SeaweedFS replaces the shared/dev
store only (WO-M1 and WO-M5 stand, as built). test-client-dental keeps its own VPS as its live path, and
WO-M3's tenant-row edit must not touch it. This is a correction of my plan, not a product decision: it
restores the model that is documented in the code. Consolidating would reverse what the client bought — their
images on their own VPS — and would put clinical imaging on shared platform infrastructure, which is a DSPT
question nobody has scoped and which the client-tier design (a VPS holding its own database and its own storage)
exists to avoid. If (b) is ever wanted it is Matthew's product decision and must be recorded as one.
What that does to the work orders (the table below is kept as the version as first raised):
| # | As revised after Phase 0 |
|---|---|
| WO-M2 | Narrowed to the shared/dev store: mirror dental-radiographs (67 objects, demo_*) into the primary's SeaweedFS, verify counts, read samples back. dental-radiographs-test and patient-images are empty — nothing to copy, and the orphan bucket stays undecided. |
| WO-M3 | No longer a tenant change. It becomes: find and repoint the consumer of the shared store (whoever resolves minio:9000 — the dev instance on the hub network is the likely one), restart, verify. The test-client-dental tenant row is left exactly as it is. |
| WO-M4 | Promoted from tail item to the live leg. The dental Client VPS holds the tenant's real images, so its swap is what actually retires the risk for that tenant — and it needs one access event, which we have. Corrected 2026-10-15, twice: the page first said "needs a build visit" and then "blocked on a credential nobody holds" — both wrong, both from reading the GP Client VPS's revoked key as if it belonged to this one. We hold root SSH to it (/root/.ssh/vps_client on the primary, verified live 2026-10-15), so the swap was executable and waited only on Matthew's authorisation — which came the same day: WO-M4 is DONE (see the runbook and the WO-M4 section below). Port parity held — the new gateway binds the port the tunnel already forwards, so neither the tunnel nor the tenant row changed. The accepted risk for the store is now closed on both copies; the agent half of the acceptance is not, because no agent exists on that VPS yet. |
| WO-M6 | Unchanged, and now clearer: MinIO on the primary goes after a backup cycle and a restore drill; the dental Client VPS's MinIO only after WO-M4 — both done 2026-10-15. |
Also from Phase 0, worth recording: the tenant's referenced set is 7 keys, 6 present — one missing
placeholder. That is a pre-existing gap the migration did not create, and it should be visible rather than
inherited silently.
| # | Scope |
|---|---|
| WO-M1 | Stand SeaweedFS up beside MinIO on the primary. Add the service to /opt/infra with its own bind mount for data (deliberate — clinical images must be walkable by the backup/DR path, not locked in a named volume) and its own 0600 S3 config with a dedicated credential, never MinIO's root user; join the existing hub network; a healthcheck that actually answers (choose the endpoint that does — do not assume one). Acceptance: the app's seven calls proven against a test bucket using the app's own SDK, and a presigned GET fetched over HTTP and byte-compared. No cutover, no config change, MinIO untouched. |
| WO-M2 | Copy the data. Mirror dental-radiographs and dental-radiographs-test (mc mirror or rclone against the S3 endpoint), then verify object counts match and read a sample of DICOMs back. The orphan patient-images bucket is left alone and undecided — nothing is deleted on assumption. Acceptance: matching counts and a sample read-back, with the app's config still pointing at MinIO. |
| WO-M3 | Cutover. Repoint the endpoint (the apps host's environment — see above) and the tenant row for test-client-dental (host, port, keys, bucket); restart the dental stack (up/restart, never down); then prove one real ingest end to end: Orthanc → DICOM ingest → object storage → a presigned view in the app. Acceptance: one new radiograph through the whole path, and an older pre-migration radiograph still viewable. |
| WO-M4 | The dental Client VPS's own copy (client-dental-vps, reached over the tunnel forward on 5438). It runs the same withdrawn image, and this was confirmed in place. The leg was executable once the credential question was settled (corrected 2026-10-15 — we hold the login; two earlier answers said otherwise). DONE 2026-10-15 under Matthew's authorisation: swapped at port parity, MinIO stopped and retained. Acceptance: met for the first half — radiographs still served (6/7 referenced objects through the tunnel); the agent's refresh list cannot be switched yet because no agent is installed on that VPS. |
| WO-M5 | The cold standby. It carries no MinIO service at all, so a restore onto it cannot serve radiographs today. Add SeaweedFS to /opt/cold-standby and to the restore drill's expectations. |
| WO-M6 | Decommission MinIO — after a window, never on the day. Leave the container and its data untouched and read-only until SeaweedFS has served production through at least one full backup cycle and one restore drill; then remove the service and drop the digest pin. Only at that point is the frozen image surplus. |
| WO-M7 | Documentation and evidence. This page; the Phase 8 DICOM/PACS page; the imaging and backup/DR pages; the patching pages that name MinIO as a client-tier image (design §7.1, patching-how-it-works, patching-promises); the image-refresh sweep's per-host stack lists in veripath-ops/image-refresh/; and a maintenance record row for the swap, so it sits in the evidence trail like every other run. |
WO-M2, WO-M3 and WO-M4 were re-scoped by the Phase 0 findings and the ruling above — the rows in this
table are the version as first raised, kept as the record.
Rollback, true throughout: both services run side by side on the same host, different port and different
volume; MinIO's data is never written to after cutover and never pruned; rollback is reverting the endpoint and
the tenant row and restarting the stack. That is precisely why WO-M6 is last.
Authoritative — written by OpenCode, correcting Hermes's reconstruction above the fold. History: my earlier
record on this page was overwritten by an edit of Hermes's written from a copy fetched before mine; the wiki has
no merge, so the last write won. Hermes reconstructed it and asked me to replace it with the authoritative
version — this is that replacement. Both sides now re-fetch a page immediately before writing.
| Item | As built |
|---|---|
| Compose | veripath-ops/infra/docker-compose.yml → deployed to /opt/infra/docker-compose.yml |
| Service | seaweedfs (container_name: seaweedfs), restart: unless-stopped, joins gp_booking_network |
| Image | chrislusf/seaweedfs:4.48@sha256:4e61d15fd35994cb1e43e1e553dff106794841fd9a99ade2fc8c8bfce4d7872d (multi-arch index digest) |
| Command | server -s3 -s3.port=9000 -s3.config /etc/seaweedfs/s3.json -dir=/data |
| Data | /opt/seaweedfs/data bind-mounted at /data (walkable by the backup/DR path) |
| Credentials | /opt/seaweedfs/s3.json, mode 0600, fresh pair (identity dental-app), owned by uid/gid 1000 (seaweed) — the image entrypoint drops privileges to that user, so a root-owned 0600 file would be unreadable |
| Healthcheck | curl -fsS http://127.0.0.1:9333/cluster/status — the S3 port returns 403 to an unauthenticated GET /; the master status path is the one that answers |
| Limits | cpus 1.0 / memory 1G, logging: *default-logging (house style) |
| Brought up | docker compose up -d seaweedfs — healthy in ~9 s |
| Acceptance | the app's seven calls (minio==7.2.13) plus a presigned URL fetched over HTTP and byte-compared — 10/10 |
| MinIO | untouched, still Up (healthy), its 67 objects intact |
dental-radiographs (67 objects, 17.54 MiB) from MinIO into SeaweedFS, inside the hub network.src=67 dst=67 compared=67 mismatches=0, none missing or extra).dental-radiographs-test and patient-images are empty — nothing to copy; the orphan bucket stays undecided.MINIO/S3 endpoint env or mount; no host service, timer or cron resolves minio:9000; minio does not resolve/root/gp_booking_app_dev, unit gp-booking-dev, inactive) is dead.MINIO_ENDPOINT minio:9000 → seaweedfs:9000 in the dental app'sconfig/secrets/env.production on both hosts (deployed app engine + dev copy); recreateddental_app / dental_celery_* on the app engine (docker compose up -d, never down); verified healthy,MINIO_ENDPOINT=seaweedfs:9000 present in the container.test-client-dental tenant row is untouched — id=4 → 172.18.0.1:5438, dental-radiographs re-verifiedThe standby now carries a SeaweedFS service (standby-seaweedfs); volume-backup.sh backs up /opt/seaweedfs/data
and the credential file; deploy-standby.sh and the Cold Standby page are
updated. Flagged, not fixed: the standby stack runs the GP app only — there is no dental app there, so
storage alone does not make imaging work end to end on a restored host.
The patching pages (design §7.1, patching-how-it-works, patching-promises) are Hermes's. Mine are done: the
image-refresh/vps1 list names seaweedfs (and cold-standby seaweedfs), and the imaging / backup / DR pages plus
the DSPT patching log carry dated updates.
Executed under Matthew's authorisation, 2026-10-15, on the dental Client VPS 194.164.18.33 (reached as root
via /root/.ssh/vps_client). It ran client_db, dental_orthanc and — the target — minio (the withdrawn
community image RELEASE.2025-09-07), serving one bucket dental-radiographs (123 objects, 76.59 MiB) on
127.0.0.1:9000, which its primary-tunnel.service forwards as -R 127.0.0.1:5438:127.0.0.1:9000.
Access, corrected (the second correction about the same thing): we do hold root SSH to this VPS. The two
earlier wrong answers — "cannot be done remotely / build visit", then "blocked on a credential nobody holds" — both
came from conflating this VPS with the GP Client VPS, whose key was revoked 2026-08-11; the primary's /root/.ssh
was also read from a truncated ls that cut off before vps_client. Lesson: reachability and authorisation are
separate questions, tested on the host in question with the credential that host would use.
As built — port parity, credentials unchanged.
chrislusf/seaweedfs:4.48@sha256:4e61d15f…),/opt/seaweedfs/data, fresh 0600 s3.json (uid/gid 1000), S3 on the VPS's 9000.minioadmin + the MinIO rootdental-radiographs MinIO→SeaweedFS on the VPS (rclone, temporary 8333), then verified: 123/123.dcm, aprocessed/ PNG, and the multipart-ETag objects MinIO could not itself hash). Cut over by stopping MinIO and76,392,599 — a transcription slip from the rclone MiB figure, not a real difference; Hermes caught it and80,309,999. See the verification below.)172.18.0.1:5438): gateway answers 403 unauthenticated andmanage.py migrate_tenant_radiographs --tenant test-client-dental --verify →placeholder.png); and the app's own client did put → readback →Exited (0), minio-data volume intact, 79 MB); rollback = docker stop seaweedfs && docker start minio. Untouched and unpruned until the WO-M6 window.Still to do (step 6): the agent image_refresh list — this VPS has no patch agent installed, so there is no
list to update yet; when one is installed (§10 build visit) it must name SeaweedFS, not MinIO.
Independent verification — Hermes, 2026-10-15 (after the swap). Read-only, from outside the build's tooling, one
deliberate login: seaweedfs up and minio Exited (0) with client_db / dental_orthanc untouched; the new
service pinned to the index digest with -s3.port=9000, the bind mounts and a 0600 config; the store serving 123
objects / 80,309,999 bytes, every one of them written inside the mirror hour; the tenant row unchanged; and
migrate_tenant_radiographs --verify reporting 6 present / 1 missing (the known placeholder.png) through the
tunnel. One number does not reconcile: the as-built record quotes the mirror as "123/123 objects, 76,392,599
bytes both sides", while the store reports 80,309,999 bytes for those 123 objects (difference 3,917,400, and
not a subtree — raw/* 55,741,172 and processed/* 24,568,827). Since nothing was written after the mirror, that
figure must be re-taken or explained; it is the only number in this migration that does not reproduce.
The primary's MinIO was decommissioned 2026-10-15 once the gate closed. veripath-ops af46534.
Gate — both evidence halves satisfied.
StartedAt, 0 restarts). Procedure recorded as Drill B onWhat was done on the primary (2026-10-15).
seaweedfs_data_20261015-013001.tar.gz (17.2 MB) andseaweedfs_config_20261015-013001.tar.gz both gzip -t clean, carrying data/dental-radiographs_* and s3.json.minio:9000, and MinIO held no active clients — safe to stop.minio service and its digest pin are gone from /opt/infra/docker-compose.yml (deployed and inveripath-ops); the minio container was stopped and removed. The gp_booking_network hub and every otherdocker compose down was not run, and a blanket up -d was deliberatelygp_booking_postgres and livekit, but not from config-hashminio/minio image (241 MB, 14cea493d9a3)/opt/minio/data. Rollback is re-adding the service and reverting the endpoint. The surplus image is notOutstanding: the dental Client VPS's MinIO is now stopped and retained (WO-M4 done 2026-10-15): the
container is Exited (0) and the minio-data volume is intact — rollback = docker stop seaweedfs && docker start minio. What remains for the client half is the decommission itself (drop the retained image/volume on the VPS),
on the same backup-cycle evidence rule as the primary.
WO-M4's step 6 — done 2026-10-15. That VPS now has the patch agent installed (the first install anywhere: the
canary), and its image_refresh list names the box's own images — the client-tier PostgreSQL, SeaweedFS and
Orthanc — so the list this migration was waiting on exists and no longer names MinIO. Caveat found at install time:
that box runs no docker compose, so a refresh there pulls and reports rather than recreating the containers.
Independent verification — Hermes, 2026-10-15 (after the decommission). Measured rather than accepted:
minio no longer appears as a service in /opt/infra/docker-compose.yml (a comment records the decommission and the
rollback position), no minio container exists, the frozen image (minio/minio:latest, 241 MB, 14cea493d9a3) and
/opt/minio/data (19 MB) are both retained, and seaweedfs is healthy with the other five services untouched
(Up 2 weeks — nothing was recreated). One finding against the work-order's own record: the stated reason for
avoiding a blanket up -d — that it would recreate gp_booking_postgres and livekit on config-hash drift — is
not reproducible from the current state: comparing each running container's com.docker.compose.config-hash
with the file's computed hash shows all six services in sync, so up -d would recreate nothing today. Either it
was a transient mid-edit state or something resolved it; recorded as observed then, not reproducible now, and the
caution about a blanket up -d in this stack stands for the moment the file does drift.
Docs close-out: the image-refresh lists already name seaweedfs; the README and nginx comment are done. A
closing line for the DSPT log item 8 is still to add.
Done from outside the build's own tooling, because a claim of "67/67 byte-identical" is exactly the sort of thing
that should be re-run by someone else:
.dcm and two processed/ PNGs,dental-radiographs), consistent with theseaweedfs up and healthy; minio still up and untouched (no restart, no-s3.port=9000 and -dir=/data; the0600.dental_app and both dental_celery_* containers recreated andimage_refresh list must not beWhere it goes. The MinIO service lives in the primary's /opt/infra stack, which already owns the hub
network — so SeaweedFS joins it as a sibling service in the same compose file: one file to diff, the same
network, and the side-by-side rollback stays a one-line change. Its state lives outside the stack on purpose:
| Item | Path | Why |
|---|---|---|
| Data | /opt/seaweedfs/data (bind mount) |
these are clinical images: the backup and DR paths have to be able to walk the filesystem, so never a named volume |
| S3 credentials | /opt/seaweedfs/s3.json, mode 0600 |
dedicated to this service — never MinIO's root user, never in git |
| Compose service | a new seaweedfs service in /opt/infra/docker-compose.yml |
beside minio, not replacing it yet |
The service shape — upstream's documented single-container form, which runs master, volume, filer and the S3
gateway in one process:
chrislusf/seaweedfs:4.48@sha256:4e61d15fd35994cb1e43e1e553dff106794841fd9a99ade2fc8c8bfce4d7872d (the multi-arch index — see the erratum above; not eb1b8173…, which is arm/v7)command: ["server", "-s3", "-s3.config", "/etc/seaweedfs/s3.json", "-dir", "/data"]restart: unless-stopped, joined to the existing hub network, and resource limits in the same house style as-dir explicitly and mount the bind path at /data. The default data directory is not a durableThe credential file. Upstream's example is an identities array carrying accessKey / secretKey and an
action list (Admin, Read, List, Tagging, Write). One trap the upstream docs state outright: the file
passed to -s3.iam.config does not support the identities field — basic service credentials belong in the
file passed to -s3.config. Generate a fresh pair for this service (the app's existing pair belongs to
MinIO and must not be reused), write it 0600 host-local, and give it to the app's environment only at cutover.
The port — settled by asking the estate, not by assuming. -s3.port exists, and the gateway was built
binding 9000, so the app-side change is the service name only. (This paragraph originally said the flag
was undocumented and not to presume one; Phase 0 corrected it. The lesson is the one this appendix's last line
already states — check the estate, then record it.)
The first stretch of WO-M1, as a runbook:
minio and bring it up (docker compose up -d seaweedfs — never down).The work orders above remain the instruction — this appendix only removes the guesswork from WO-M1. Anything
here that contradicts what the running system actually does wins the argument: check, then record the correction
on this page.
patient-images bucket — decide it; do not delete it on assumption.image_refresh)