Report Artifact Storage & Versioning
Once a pipeline renders spatial-report PDFs unattended, the hard questions move downstream: which version of last quarter’s flood-risk report did the client actually receive, can you prove the file has not been altered, how long must you keep it, and how do you hand someone a link without exposing an entire bucket of sensitive location data. Naming files report_final_v2.pdf answers none of these. This guide builds a storage layer where every artifact is content-addressed, immutable, catalogued with queryable metadata, distributed through expiring signed URLs, retained on a lifecycle policy, and promoted between environments by reference rather than re-render.
Prerequisites
- Python 3.10+ with
boto3>=1.34for S3/MinIO access (the S3 API covers both) - An object store: AWS S3, or MinIO for self-hosted deployments — both speak the same API and support versioning and object lock
- Deterministic renders: PDFs produced with a fixed
SOURCE_DATE_EPOCH, as set up in Headless PDF Rendering in Docker Containers — content-addressed storage is only meaningful when identical inputs yield identical bytes - A batch producing artifacts: the run manifests from Scheduled Batch Report Generation become the source records this catalog ingests
- A catalog store: a small relational table (SQLite, Postgres) or a DynamoDB table to index artifacts by report, version, and hash
pip install "boto3>=1.34"
# MinIO for local development, S3-compatible:
docker run -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001"
Pipeline Architecture
A rendered PDF is hashed, uploaded under its content-addressed key to an immutable bucket, and indexed in a metadata catalog. Consumers resolve the current version through the catalog and receive a short-lived signed URL; a lifecycle policy ages old versions to cheaper storage or expiry, and promotion copies an approved hash from staging to production.
Step-by-Step Implementation
Step 1: Compute a Content Hash for Each Rendered PDF
Hash the PDF bytes with SHA-256 and use a prefix of the digest as the object key. This makes the artifact self-verifying — any consumer can re-hash a download and confirm integrity — and naturally deduplicating, because identical inputs render to identical bytes and therefore the identical key.
from __future__ import annotations
import hashlib
from pathlib import Path
def content_key(pdf_path: Path, report_id: str) -> str:
"""Return an object key namespaced by report and addressed by content hash."""
digest = hashlib.sha256(pdf_path.read_bytes()).hexdigest()
return f"reports/{report_id}/{digest}.pdf"
Content addressing depends entirely on deterministic rendering: if WeasyPrint stamps a live timestamp into the PDF, two identical reports get different hashes and the deduplication breaks. The SOURCE_DATE_EPOCH setup in Headless PDF Rendering in Docker Containers is the prerequisite that makes this step trustworthy.
Step 2: Upload to Immutable, Content-Addressed Object Storage
Enable bucket versioning and Object Lock, then upload under the content key. Because the key is derived from the bytes, an upload of already-present content is a harmless no-op; you can short-circuit it with a head_object check to save bandwidth.
import boto3
from botocore.exceptions import ClientError
s3 = boto3.client("s3")
BUCKET = "spatial-reports-prod"
def upload_artifact(pdf_path: Path, key: str) -> bool:
"""Upload if absent. Returns True if newly stored, False if already present."""
try:
s3.head_object(Bucket=BUCKET, Key=key)
return False # content-addressed: identical key means identical bytes
except ClientError as exc:
if exc.response["Error"]["Code"] != "404":
raise
s3.put_object(
Bucket=BUCKET,
Key=key,
Body=pdf_path.read_bytes(),
ContentType="application/pdf",
ObjectLockMode="GOVERNANCE",
ObjectLockRetainUntilDate=_retain_until(days=1825), # 5-year retention
)
return True
GOVERNANCE mode lets a privileged role lift retention if legally required, while blocking ordinary overwrites and deletes; use COMPLIANCE mode when even administrators must not be able to alter the object before expiry. The versioning + lock combination is what lets you promise that a report referenced by hash resolves to the same bytes years later.
Step 3: Record Artifact Metadata in a Queryable Catalog
The object store answers “give me these bytes”; the catalog answers “what is the current version of the Q2 flood report, who produced it, and from which inputs.” Record one row per stored artifact, linking report identity to the content hash and the run that produced it.
import sqlite3
from datetime import datetime, timezone
def catalog_artifact(
conn: sqlite3.Connection,
report_id: str,
key: str,
run_id: str,
template_version: str,
) -> None:
digest = key.rsplit("/", 1)[-1].removesuffix(".pdf")
conn.execute(
"""
INSERT INTO artifacts (report_id, digest, key, run_id, template_version, stored_at, current)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(digest) DO NOTHING
""",
(report_id, digest, key, run_id, template_version,
datetime.now(timezone.utc).isoformat()),
)
# Demote prior current versions of the same report.
conn.execute(
"UPDATE artifacts SET current = 0 WHERE report_id = ? AND digest != ?",
(report_id, digest),
)
conn.commit()
The catalog ingests directly from the run manifests emitted by Scheduled Batch Report Generation, so every stored artifact traces back to the exact batch, template version, and input date that produced it.
Step 4: Distribute with Time-Limited Signed URLs
Never make a report bucket public — spatial reports routinely embed sensitive parcel, infrastructure, or personal location data. Keep the bucket private and mint a presigned URL per request, scoped to one object and expiring in minutes to hours.
def presign(key: str, expires_seconds: int = 900) -> str:
"""One-object, time-limited download link — no bucket-wide exposure."""
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": key},
ExpiresIn=expires_seconds,
)
A dashboard or notification service resolves the current key from the catalog, then hands the consumer a fresh presigned URL. Because the link expires, a forwarded email or cached URL cannot be replayed indefinitely.
Step 5: Apply Lifecycle Retention and Promote Between Environments
A lifecycle policy ages superseded versions to cheaper storage and expires them past the retention horizon, controlling cost without manual cleanup. Object Lock still guarantees objects survive until their retain-until date regardless of the lifecycle rule.
{
"Rules": [
{
"ID": "tier-and-expire-old-report-versions",
"Filter": { "Prefix": "reports/" },
"Status": "Enabled",
"Transitions": [
{ "Days": 90, "StorageClass": "STANDARD_IA" },
{ "Days": 365, "StorageClass": "GLACIER" }
],
"NoncurrentVersionExpiration": { "NoncurrentDays": 1825 }
}
]
}
Promotion moves an approved artifact from staging to production by reference, not by re-rendering — copy the immutable object under its same hash key, then flip the production catalog’s current pointer. This guarantees production ships the exact bytes staging reviewed. The packaging and manifest conventions for bundling related outputs together are covered in Publishing Report Bundles to an Artifact Registry.
Production-Ready Script
This uploader ties the flow together: hash each PDF from a completed batch, upload immutably, catalog it, and print a presigned URL, all behind an argparse interface with logging and error handling. The bucket-specific versioning details for S3 in particular are expanded in Versioning Generated PDFs in S3 Object Storage.
#!/usr/bin/env python3
"""
store_artifacts.py — content-address, upload, catalog and sign report PDFs.
Usage:
python store_artifacts.py --dir output/ --report-id q2-flood --run-id 20260711-ab12cd34
"""
from __future__ import annotations
import argparse
import hashlib
import logging
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import boto3
from botocore.exceptions import BotoCoreError, ClientError
logging.basicConfig(
level="INFO", format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S"
)
logger = logging.getLogger("store")
BUCKET = "spatial-reports-prod"
CATALOG_DB = Path("catalog.sqlite")
s3 = boto3.client("s3")
def _retain_until(days: int) -> datetime:
return datetime.now(timezone.utc) + timedelta(days=days)
def content_key(pdf: Path, report_id: str) -> str:
digest = hashlib.sha256(pdf.read_bytes()).hexdigest()
return f"reports/{report_id}/{digest}.pdf"
def ensure_catalog(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS artifacts (
digest TEXT PRIMARY KEY,
report_id TEXT NOT NULL,
key TEXT NOT NULL,
run_id TEXT,
template_version TEXT,
stored_at TEXT NOT NULL,
current INTEGER NOT NULL DEFAULT 1
)
"""
)
conn.commit()
def store_one(pdf: Path, report_id: str, run_id: str, conn: sqlite3.Connection) -> str:
key = content_key(pdf, report_id)
try:
s3.head_object(Bucket=BUCKET, Key=key)
logger.info("Already stored: %s", key)
except ClientError as exc:
if exc.response["Error"]["Code"] != "404":
raise
s3.put_object(
Bucket=BUCKET, Key=key, Body=pdf.read_bytes(),
ContentType="application/pdf",
ObjectLockMode="GOVERNANCE",
ObjectLockRetainUntilDate=_retain_until(1825),
)
logger.info("Stored: %s", key)
digest = key.rsplit("/", 1)[-1].removesuffix(".pdf")
conn.execute(
"INSERT OR IGNORE INTO artifacts "
"(digest, report_id, key, run_id, template_version, stored_at, current) "
"VALUES (?,?,?,?,?,?,1)",
(digest, report_id, key, run_id, "2026.07",
datetime.now(timezone.utc).isoformat()),
)
conn.execute(
"UPDATE artifacts SET current=0 WHERE report_id=? AND digest!=?",
(report_id, digest),
)
conn.commit()
return key
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Store and version report artifacts")
parser.add_argument("--dir", required=True, type=Path)
parser.add_argument("--report-id", required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--sign-seconds", type=int, default=900)
args = parser.parse_args(argv)
conn = sqlite3.connect(CATALOG_DB)
ensure_catalog(conn)
pdfs = sorted(args.dir.glob("*.pdf"))
if not pdfs:
logger.error("No PDFs found in %s", args.dir)
return 2
for pdf in pdfs:
try:
key = store_one(pdf, args.report_id, args.run_id, conn)
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": key},
ExpiresIn=args.sign_seconds,
)
logger.info("Signed URL (%ds): %s", args.sign_seconds, url)
except (BotoCoreError, ClientError) as exc:
logger.error("Failed to store %s: %s", pdf.name, exc)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Edge Cases & Advanced Configuration
Deduplicating Identical Reports Across Runs
When two scheduled runs produce a byte-identical report — common when the underlying data did not change overnight — content addressing collapses them to one stored object automatically. The catalog still records both runs pointing at the same digest, so you preserve the audit trail (“this report was current for three consecutive nights”) without paying to store three copies.
Large Bundles and Multi-File Reports
A single logical report is often a bundle: the PDF, its source GeoJSON, a thumbnail, and a manifest. Hash the bundle’s manifest (which itself lists each member’s hash) to get a single addressable bundle version, then store members individually so unchanged members deduplicate. The registry-oriented packaging model for this is detailed in Publishing Report Bundles to an Artifact Registry.
Retention Conflicts with GDPR Erasure
Compliance-mode Object Lock and a right-to-erasure request can collide: locked objects cannot be deleted before expiry. Resolve this at design time by keeping personally identifying data out of the immutably stored PDF where possible, or by using GOVERNANCE mode plus a documented privileged-deletion process rather than COMPLIANCE mode for reports that may contain personal data.
Cross-Region and Multi-Environment Catalogs
When staging and production live in different accounts or regions, keep a catalog per environment and promote by copying the object and inserting a catalog row — never by pointing production at a staging URL. Reports whose large attribute tables were paginated per Table Pagination Strategies for Large Attribute Tables can be sizable, so prefer a same-region server-side copy_object over download-and-reupload to avoid egress cost.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Every nightly run creates a new version of an unchanged report | Non-deterministic render (live PDF timestamp) | Set SOURCE_DATE_EPOCH; verify identical inputs hash to the same key |
AccessDenied when uploading with Object Lock fields |
Bucket versioning or Object Lock not enabled | Enable versioning and Object Lock at bucket creation; they cannot be added retroactively |
| Consumers report a broken/expired link | Presigned URL past its ExpiresIn window |
Resolve the current key from the catalog and mint a fresh URL per request |
| Cannot delete an object during cleanup | Object Lock retention still active | Wait for retain-until, or use a GOVERNANCE-mode privileged bypass; never public-ACL as a workaround |
| Production shows a different report than staging approved | Re-rendered per environment instead of copying bytes | Promote by copying the immutable hash-keyed object; flip the catalog current flag |
| Storage cost grows unbounded | No lifecycle policy on noncurrent versions | Add tiering transitions and NoncurrentVersionExpiration to the lifecycle config |
Frequently Asked Questions
Why name report PDFs by content hash instead of a timestamp or sequence number?
A content hash makes the artifact self-verifying and deduplicating: identical inputs produce the identical key, so re-runs never create spurious versions and any consumer can confirm a download is intact by re-hashing it. Timestamps and sequence numbers collide, hide duplicates, and cannot detect corruption. For content hashing to work the render itself must be deterministic — set SOURCE_DATE_EPOCH so the PDF creation date does not perturb the bytes.
How do I make stored report artifacts immutable?
Enable object versioning on the bucket and apply an S3 Object Lock retention policy in compliance or governance mode, or the MinIO equivalent, so a key cannot be overwritten or deleted before its retention period elapses. Combined with content-addressed keys, immutability guarantees that a report referenced by hash today resolves to exactly the same bytes months later — essential for audit and regulatory reporting.
How should I distribute report PDFs without making the bucket public?
Keep the bucket private and hand out time-limited presigned URLs generated per request. A presigned URL grants read access to one object for a short window without exposing credentials or the rest of the bucket, so you can embed a link in an email or dashboard that expires automatically. Never make a report bucket public — spatial reports frequently contain sensitive location data.
How do I promote a report artifact from staging to production?
Promote by reference, not by re-rendering. Because the artifact is content-addressed, promotion is copying the immutable object (or just its catalog pointer) from the staging bucket to the production bucket under the same hash key, then updating the production catalog to mark that version current. Re-rendering in each environment risks byte drift; copying the exact reviewed bytes guarantees production ships what staging approved.
Detailed Guides in This Section
- Versioning Generated PDFs in S3 Object Storage — bucket versioning, Object Lock modes, and noncurrent-version lifecycle rules for report PDFs specifically.
- Publishing Report Bundles to an Artifact Registry — package a PDF with its sources and manifest into a single versioned, promotable bundle.
Related
- Scheduled Batch Report Generation — the batches and run manifests that feed this storage catalog
- Headless PDF Rendering in Docker Containers — deterministic rendering that makes content-hash naming reliable
- Table Pagination Strategies for Large Attribute Tables — controls the size of the artifacts you store and copy between environments
- Print-Ready Page Sizing Standards for GIS Reports — the page specs that define what a stored, versioned report should look like
- Jinja2 Templating & Theme Logic for Automated Spatial Reporting — the templating layer whose version is recorded alongside each artifact
Parent: CI/CD Scheduling & Automation for Spatial Report Pipelines
Conclusion
Treating each generated report as a content-addressed, immutable object — indexed in a catalog, distributed through expiring signed URLs, aged by a lifecycle policy, and promoted by reference — turns a directory of ambiguously named PDFs into an auditable, verifiable artifact history. That storage discipline is the durable end of the CI/CD Scheduling & Automation for Spatial Report Pipelines workflow, guaranteeing that every report a client received can be reproduced, verified, and traced back to the exact run that produced it.