Object storage for engine documents — infra answer (2026-08-13)
Question routed from the OCR session: should we have used MinIO for document storage?
Verdict: adopt object storage, but not now, and not on either of the two options that were proposed. Sequence it behind the production migration and the reconciler. Meanwhile, the survey done to answer this question turned up something more urgent than the question itself — see The finding that outranks the question.
Three corrections to the premise
The OCR session framed this as "extend Artesca vs stand up a separate MinIO — and AHU already has MinIO live." All three parts need adjusting.
| Claim | Correction | Evidence |
|---|---|---|
| "AHU already has MinIO live" (meaning Artesca) | s3.artesca.ahu.go.id is not MinIO. It is NetApp Artesca, AHU-operated. |
TLS leaf: issuer=CN=Artesca-CA, subject=CN=*.ahu.go.id, O=Direktorat Jenderal Administrasi Hukum Umum |
| "stand up a separate MinIO" = greenfield deploy | We already run our own MinIO — ahu-minio on ahu-backup-01, live right now. The compose + OpenBao cred pattern is already written and proven. |
platform-infra/backup/compose.minio.yaml; http://192.168.82.126:9000/minio/health/live → 200, Server: MinIO |
| "MinIO unlocks horizontal scale for OCR" | Necessary but not sufficient. Shared storage removes one of at least three replica blockers, and it is the least dangerous of them. | outbox-drain.ts:131, uploads-retention.ts:80 — see below |
The multi-replica claim, checked
This was the strongest argument in the OCR session's list, so it deserved verification rather than agreement. Shared storage alone does not make the OCR backend safe to run at two replicas. Two unguarded in-process schedulers would misbehave the moment a second replica boots:
backend/src/persistence/outbox-drain.ts:131
const timer = setInterval(async () => {
if (running) return; // <- per-PROCESS guard only
The comment calls this "overlap-safe", and within one process it is. There is no advisory lock, no SELECT … FOR UPDATE SKIP LOCKED, no leader election — so two replicas hold two independent running flags and claim the same outbox rows twice. Duplicate outbound side effects.
backend/src/security/uploads-retention.ts:80
setInterval(run, SWEEP_INTERVAL_MS);
Every replica runs its own retention sweeper. Two concurrent sweepers racing on deletes — which is precisely the class of bug that caused today's incident, now multiplied by replica count.
So "the OCR backend cannot run more than one replica today without shared storage, full stop" is true but incomplete. Storage is blocker #1 of ≥3, and moving to MinIO would fix the one that loses no data while leaving the two that do.
The finding that outranks the question
ahu-backup-01 was delivered, MinIO was deployed, a bucket was created — and nothing has ever been written to it.
minio_cluster_bucket_total 1
minio_cluster_usage_object_total 0 <- zero objects, ever
minio_cluster_capacity_usable_free 1.99 TB / 2.00 TB
minio_cluster_drive_offline_total 0
There is no backup client anywhere in platform-infra/ — no restic, no pgbackrest, no wal-g, no mc config. The repo contains exactly two files for this host: the MinIO server compose and the credential renderer. The server is up; the thing it exists to receive was never built.
The design intent (2026-07-09-devops-server-request-2.md) was PITR/WAL streaming from ahu-gov-01's TimescaleDB — the audit-integrity store — plus pg_dump from ahu-int-01's Postgres/pgvector. TimescaleDB is classed "crown jewels" in the architecture doc. Per the PDP assessment, ai_call_bodies in that store holds NIK.
Net position: the audit store containing citizen NIK has a 2 TB backup target sitting empty, and no job pointed at it. That should be confirmed on-host (.121 is not SSH-reachable from where I can currently probe, so I can only prove the destination is empty, not that no backup exists by some other route). But nothing in the infra repo suggests another route exists.
This is a bigger exposure than the document-storage question that prompted the survey, and it is cheap to close because the destination is already running.
Two more gaps found in the same sweep
1. The MinIO is plaintext HTTP. No TLS config, no cert volume, http://…:9000 answers 200. It is intended to carry backups of a store holding NIK, across hosts. That needs TLS before it carries its first byte — which, conveniently, is right now.
2. Firewall drift on the backup zone. The stated posture is push-only from .121/.122, DWH-grade isolation, explicitly denied from DMZ/internet. From the GPU host (192.168.83.20, a different subnet entirely):
| Target | Result |
|---|---|
82.126:9000 (MinIO S3) |
OPEN |
82.126:22 |
closed |
82.120:8250 (OpenBao) |
OPEN |
82.120:6379 (Redis) |
OPEN |
82.121:8300, 82.121:5432 |
OPEN |
82.122:5432 |
closed |
82.120:8200 (Gateway) |
closed — nothing listening yet |
SSH-via-bastion-only holds. But service ports are broadly reachable from 83.x, including the backup zone that is supposed to be the most isolated thing we have. Either that is an intentional allow I should document, or it is drift I should close. Given the host is meant to hold audit/PII one hop removed, I lean close it.
(Note the double edge: this same open path is what would make MinIO adoption cheap for OCR — no new network work needed. Convenience and posture are pointing in opposite directions here, and posture should win.)
The actual three-way decision
A. Artesca (s3.artesca.ahu.go.id) |
B. Existing ahu-minio (.126) |
C. New MinIO for engine docs | |
|---|---|---|---|
| Control over lifecycle rules / quotas / versioning | None — every change is an AHU ticket | Full | Full |
| TLS | Yes, but internal CA w/ incomplete chain | No | Yes, by construction |
| Redundancy | AHU-managed | None — single drive, server /data, no erasure coding |
Design in from day one |
| Failure-domain separation from backups | Yes | No — collapses app traffic into the backup target | Yes |
| Known operational risk | Leaf cert expires 2026-10-03 (~7 wks); renewal outside our control, and our pinned-insecure SigV4 path breaks if the CA rotates | Backup host contention | New deploy to own |
| Verdict | Reject for engine storage | Reject as primary | Recommended, later |
A — Artesca: keep it strictly as a read-only upstream for specimen sheets. That is what it is good for and it already works. Do not put PoC upload churn into AHU production — Efran's instinct here was right. The cert expiry is a concrete date worth tracking regardless, because the specimen fetch path depends on it: the custom SigV4 client in lib/s3-signed-fetch.ts exists precisely because that chain is incomplete, and a CA rotation on Oct 3 is a live break risk for a path we already depend on.
B — the existing MinIO: do not repurpose it. Putting live application traffic on the backup target collapses two failure domains into one; a runaway uploads bucket would then threaten the PITR destination. It is also single-drive mode — zero parity. That is acceptable for backup copies of data that exists elsewhere. It is not acceptable as the only copy of citizen PDFs.
C — a second MinIO, in the internal-engines zone. Cheap, because the pattern is already written and proven: copy compose.minio.yaml, same OpenBao AppRole cred rendering, same Prometheus scrape, different bucket layout, TLS on, lifecycle rules from day one. The marginal cost is low precisely because of the work already done for the backup host.
Why later, not now
- Nothing is blocked today. OCR runs one replica and is not close to needing two — and as shown above, storage is not the only thing standing between it and a second one.
- The disk pressure is not real pressure.
klasifikasi-tempis 903 MB on a host with 87 GB free — about 1% of headroom. A lifecycle rule is genuinely the nicer solution, but a sweeper solves it this week, and a sweeper has to be written anyway (point 4). The marginal saving from going declarative is small. - The prerequisite is the production migration, not the object store. OCR still runs on the GPU host (
ahu-ai-ocr-webon192.168.83.20), not onahu-int-01where the network map puts it. Migrating storage before migrating the app means doing the storage work twice and pushing upload traffic across83→82in plaintext. Move the app first; the storage decision gets simpler once the app sits in the zone that owns its data. - The reconciler should be funded first, and it is storage-independent. The OCR session's own closing point is the right one: 20% orphan rate moves into a bucket where
lsandduno longer work. Their fitness test is the real asset here — it should be generalized to a reconciliation job before the backend changes, not after.
Full agreement with the OCR session on the core technical point: S3 has no foreign keys. Object storage relocates the orphan, it does not eliminate it. And their diagnosis is right that this would have been harder to find in a bucket — that argues for building the reconciler while the data is still on a filesystem you can du.
Proposed split of work
Infra (me) — do now, independent of the MinIO question:
- Wire the actual backup jobs to
ahu-minio. The destination has been running empty since 2026-07-13. Highest value, lowest effort, already-paid-for hardware. - TLS on
ahu-minio— before it carries its first byte of NIK-bearing audit data. - Resolve the
83.x → 82.126:9000path: close it, or document it as intentional. - Track the Artesca cert expiry (2026-10-03) as a dependency of the live specimen-fetch path.
Infra (me) — do when the migration lands:
- Stand up MinIO #2 for engine documents in the internal-engines zone, with TLS and lifecycle rules on temp/derived prefixes from day one.
- Publish the bucket + prefix convention across engines (ocr / apostille / classifier) and a reusable client that handles the Artesca chain — the constraint that ruled out stock
Bun.S3Client.
OCR session — do now, storage-independent:
- Generalize the fitness test into a standing reconciliation job. This is the one that must not wait for a storage decision.
- If
klasifikasi-tempgrowth becomes real before MinIO #2 exists, a sweeper is fine — write it so its policy is portable to an S3 lifecycle rule later. - Before any multi-replica work: put a cross-process lock on
outbox-drainand single-owner the retention sweeper. Storage is not the blocker people think it is.
Evidence appendix
All probes run 2026-08-13 from efran@192.168.83.20.
| Check | Result |
|---|---|
ahu-minio health |
200, Server: MinIO, anon list → AccessDenied (auth enforced) |
| Objects stored | 0 |
| Buckets | 1 |
| Capacity | 1.99 TB free / 2.00 TB |
| Drives offline | 0 |
| Transport | plaintext HTTP on :9000 |
| MinIO mode | single-drive (server /data, one volume) — no erasure coding |
Backup clients in platform-infra/ |
none (no restic / pgbackrest / wal-g / mc) |
| Artesca issuer | CN=Artesca-CA (not MinIO) |
| Artesca leaf validity | Jul 5 2026 → Oct 3 2026 |
| GPU host disk | 502 G total, 394 G used, 87 G avail (82%) |
| OCR uploads total | 1.3 G |
├ klasifikasi-temp |
903 M |
├ surat-keterangan |
25 M |
| └ largest single PDF | 7.5 M |
| OCR runtime location | ahu-ai-ocr-web on 192.168.83.20 (GPU host), not ahu-int-01 |