Skip to main content

S3: the platform's prime dependency

Pagifier deliberately has no configuration of its own beyond connection settings. Every behavioral decision — what a template provides, which base images runtimes use, how prod differs from dev, what "strict" security means, which metrics judge a canary — lives as a YAML document in an S3 bucket. Understanding the two buckets is understanding how the platform is operated.

The two buckets

BucketWritten byRead byContents
Artifact bucket (s3.artifactBucket)API server (uploads)Build jobs (download + digest verify)app.zip archives, one immutable key per release
Config bucket (s3.configBucket)Platform team onlyAPI server (config resolution)Templates, profiles, runtime definitions, policies, gates

Artifact keys are fully deterministic:

artifacts/<tenant>/<app>/<environment>/<releaseID>/app.zip

The SHA256 of every artifact is recorded on the Release at upload and re-verified inside the build job before any code runs — a swapped or corrupted object fails the build with an integrity error.

Why S3 and not the operator image

Three properties fall out of this design (see ADR-0004):

  1. Publishing is deployment-free. Supporting a new Node version, changing prod's canary steps, or adding a security header is aws s3 cp — no operator rebuild, no restart, no Helm upgrade.

  2. Running apps are immune to publishes. Configuration resolves once, at upload time, into an immutable snapshot embedded in the Release (ADR-0002). A template change affects the next upload of each project, never the fleet.

  3. Every release is auditable. The Release's templateVersions records exactly which documents produced its snapshot:

    "templateVersions": {
    "template:react-static": "2",
    "runtime:static": "current",
    "environment:prod": "current",
    "security:strict": "current",
    "analysis:error-rate": "current"
    }

Config bucket layout

platform/
defaults.yaml global defaults — the lowest merge layer
runtime-images/
static.yaml runtime definitions (one per runtime)
node.yaml
python.yaml
go.yaml
templates/
react-static/
1.yaml immutable published versions
2.yaml
latest.yaml floating pointer used by unpinned refs
nestjs/… fastapi/… go-api/…
defaults/
dev.yaml qa.yaml uat.yaml environment profiles
staging.yaml prod.yaml preview.yaml
security/
basic.yaml … pci.yaml header profiles behind `security: <name>`
signing/
<tenant>.yaml per-tenant artifact-signing policy
middleware/
authentication-oidc.yaml ingress auth annotation sets
authentication-jwt.yaml
authentication-oauth2-proxy.yaml
analysis/
error-rate.yaml canary metric gates behind `analysis: [name]`
latency-p95.yaml

A complete, working seed of this tree ships in the repository at examples/platform-config/; publish it with:

make sync-platform-config CONFIG_BUCKET=pagifier-platform
# equivalent to: aws s3 sync examples/platform-config/ s3://pagifier-platform/

Document schemas

platform/defaults.yaml — global defaults

A pagifier.yaml fragment (any project key is legal). This is the bottom merge layer: sane baselines every app inherits unless something above overrides them.

port: 80
resources: {cpu: 100m, memory: 128Mi}
healthCheck: {path: /, initialDelaySeconds: 3, periodSeconds: 10}
compression: {gzip: true}
security: {profile: recommended}
cache: {static: {maxAge: 30d}, html: {maxAge: 1m}}
ingress: {tls: true}

runtime-images/<runtime>.yaml — runtime definitions

The only place base images come from; Pagifier never inspects a project to choose one. Two image roles:

# runtime-images/go.yaml
runtime: go
images:
runtime: gcr.io/distroless/static-debian12:nonroot # base of the final app image
build: public.ecr.aws/docker/library/golang:1.26 # toolchain the build commands run in

A missing runtime definition fails uploads of that runtime with the exact object key in the error: runtime definition "go": object not found: runtime-images/go.yaml.

templates/<name>/<version>.yaml — application templates

pagifier.yaml fragments that define the golden path per framework. Covered in depth on the templates page, including the versioning and publishing workflow.

defaults/<environment>.yaml — environment profiles

A pagifier.yaml fragment plus three profile-only keys:

# defaults/prod.yaml
ha: true
replicas: 3
autoRollback: true
resources: {cpu: 250m, memory: 256Mi}
deploy:
strategy: canary
canary: {steps: [10, 50], stepInterval: 5m, analysis: [error-rate, latency-p95]}

# profile-only keys:
logging: {level: info}
podDisruptionBudget: {enabled: true, minAvailable: "50%"}
rollingUpdate: {maxSurge: "25%", maxUnavailable: "0"}

security/<profile>.yaml — header profiles

headers:
X-Frame-Options: DENY
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

Expanded into the Release snapshot when a project says security: <profile>; header values are escaped when rendered into nginx configuration.

security/signing/<tenant>.yaml — artifact-signing policies

required: true # reject unsigned uploads with 403
publicKey: | # cosign public key (ECDSA PEM)
-----BEGIN PUBLIC KEY-----

No document for a tenant means signing is not enforced for them.

middleware/authentication-<type>.yaml — edge auth

ingressAnnotations:
nginx.ingress.kubernetes.io/auth-url: https://oauth2.company.com/oauth2/auth
nginx.ingress.kubernetes.io/auth-signin: https://oauth2.company.com/oauth2/start?rd=$scheme://$host$request_uri

Resolved into the snapshot when a project enables that auth type. These annotations override project annotations, and a missing definition fails the upload — an app can never silently deploy unprotected.

analysis/<name>.yaml — canary metric gates

query: | # PromQL; {{app}} {{canary}} {{namespace}} {{tenant}} {{env}} substituted
sum(rate(nginx_ingress_controller_requests{service="{{canary}}",status=~"5.."}[2m]))
/ sum(rate(nginx_ingress_controller_requests{service="{{canary}}"}[2m]))
maxValue: 0.01 # or minValue for floors
failureLimit: 3 # consecutive failures before the canary aborts

Who reads what: the isolation model

S3 access is deliberately asymmetric — each component gets the minimum:

ComponentConfig bucketArtifact bucket
API serverread (resolution, signing policies)write + read (store uploads)
Operatornone — snapshots make re-reads unnecessarynone
Build jobsnone — the snapshot arrives via the Job specread (download + verify app.zip)
Platform teamwrite (the only writer)lifecycle management

Example IAM policies (AWS; MinIO policies are shaped the same):

// pagifier-api role
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::pagifier-platform", "arn:aws:s3:::pagifier-platform/*"]},
{"Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::pagifier-artifacts", "arn:aws:s3:::pagifier-artifacts/*"]}
// pagifier-build service account (IRSA)
{"Effect": "Allow", "Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::pagifier-artifacts/artifacts/*"]}

Credentials use the default AWS chain (IRSA on EKS, instance profiles, or env vars). For MinIO or any S3-compatible store, set the endpoint and Pagifier switches to path-style addressing automatically:

# Helm values
s3:
endpoint: http://minio.minio.svc:9000
artifactBucket: pagifier-artifacts
configBucket: pagifier-platform

Caching and failure behavior

  • The API caches config documents for 30 seconds (including not-found results), so a publish is live within half a minute and a burst of uploads costs one S3 read per document.
  • A missing required document (unknown template, undefined runtime, absent middleware/analysis definition) fails the upload with a 400 naming the exact object key — fail closed, never guess.
  • Config bucket outage: new uploads and /v1/validate fail; everything already deployed keeps running and reconciling, because the operator works entirely from Release snapshots.
  • Artifact bucket outage: uploads fail; builds in flight fail and retry on the next upload; running workloads are unaffected.

Operating the config bucket

The bucket is the platform's control plane — treat it like one:

  • Enable S3 versioning so any published document can be diffed and rolled back (aws s3api get-object --version-id …).
  • Restrict writes to the platform team (or better, a CI pipeline that reviews template changes as pull requests against examples/platform-config/ and syncs on merge).
  • Enable access logging for an audit trail of publishes.
  • Artifact bucket: add a lifecycle rule consistent with your rollback window (e.g. transition to infrequent access after 30 days, expire after your retention policy) — remember in-cluster Releases are pruned by the operator, but their artifacts and Postgres history are what make old releases re-buildable and auditable.