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
| Bucket | Written by | Read by | Contents |
|---|---|---|---|
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 only | API 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):
-
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. -
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.
-
Every release is auditable. The Release's
templateVersionsrecords 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:
| Component | Config bucket | Artifact bucket |
|---|---|---|
| API server | read (resolution, signing policies) | write + read (store uploads) |
| Operator | none — snapshots make re-reads unnecessary | none |
| Build jobs | none — the snapshot arrives via the Job spec | read (download + verify app.zip) |
| Platform team | write (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
400naming the exact object key — fail closed, never guess. - Config bucket outage: new uploads and
/v1/validatefail; 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.