LFX Object Store Ops
How the object storage backend is provisioned for deployed environments.
This is a requirements stub, deliberately non-prescriptive: it captures the
constraints the Ops team has agreed to, points at the existing conventions
in lfx-v2-opentofu, and leaves the HCL to the implementing agent.
Audience: linuxfoundation GitHub org members. This skill is written for
that ops audience and assumes org membership; it does not itself enforce
it (see the -ops skill convention in /lfx-skills:lfx for how the router
handles routing when membership is unconfirmed). Application-side design
(endpoints, SDK usage, chart contract, local dev) is
/lfx-skills:lfx-object-store-design.
Where
All resources live in
lfx-v2-opentofu:
- Environments are OpenTofu workspaces (
dev, staging, prod), not
directories. Resource names interpolate ${terraform.workspace} (see
s3.tf for existing examples).
- Workflow via the repo
Makefile (make init/plan/apply); read the repo
README.md before planning.
Pattern
Data-driven flat for_each, matching the postgres convention
(postgres-database-definitions.yaml + postgres.tf). No new module —
the Ops team prefers flat loops over YAML definitions; modules are reserved
for cases that would otherwise require nested loops (see
modules/eks-service-account-role/).
- New
object-store-definitions.yaml holding one entry per service bucket,
including a boolean flag marking whether the bucket is public
(CDN-fronted) — the design skill requires public and private files in
separate buckets, so this is a per-bucket property, never mixed.
- New
object-storage.tf with one for_each per resource type. Bucket
resources loop over every entry: aws_s3_bucket (+ versioning, SSE,
lifecycle, public-access-block). CDN resources — aws_cloudfront_origin_access_control,
aws_cloudfront_distribution, aws_s3_bucket_policy (OAC read policy),
and the DNS alias records — loop over a filtered
{ for k, v in local.object_stores : k => v if v.public } so a
distribution is only ever attached to explicitly public buckets.
Attaching CloudFront/OAC to a service-API-only bucket would make its
private objects anonymously retrievable. Still flat 1:1 loops, no
nesting.
Requirements
Buckets
- Private only. Public access block enabled; no public-read bucket
policies. The existing
lfx-one-project-logos-png-* buckets in s3.tf
are a legacy anti-pattern — do not copy them.
- Versioning and SSE enabled; names interpolate
${terraform.workspace}.
Bucket versioning here is a data-protection feature (recovery from
accidental delete/overwrite) — it is unrelated to the design skill's
cache-busting query parameter (/lfx-skills:lfx-object-store-design,
"Download flow"), which is an app-generated hint in public_url, not the
S3 VersionId this setting produces. Do not conflate the two: enabling
bucket versioning does not, by itself, invalidate CDN caches or change
what the cache-busting parameter needs to look like.
- Lifecycle rules are mandatory alongside versioning. With versioning
on,
DeleteObject only adds a delete marker and every overwrite retains
the prior payload as a noncurrent version — without lifecycle
expiration, storage grows unboundedly and user-"deleted" content is
retained indefinitely (a data-retention problem, not just a cost one).
Each bucket gets an aws_s3_bucket_lifecycle_configuration (1:1, same
flat loop) that: expires noncurrent versions after 30 days, removes
expired object delete markers, and aborts incomplete multipart uploads
after 7 days.
CloudFront
- Origin Access Control (OAC) with a
SourceArn-conditioned bucket
policy. Not the legacy OAI (aws_cloudfront_origin_access_identity) or
forwarded_values patterns — the ITX
cloudfront-distribution-with-s3 module is a structural reference only
and must not be copied (deprecated OAI, forwarded_values, TLSv1).
- Managed cache and origin-request policies that honor the per-object
Cache-Control header. This header is object metadata set by the
service at upload (PutObject's CacheControl field), not an origin
or bucket configuration — the design skill's baseline is a short TTL,
public, max-age=86400, since the cache-busting parameter is a hint
rather than an immutable version. The design skill's cache-busting query
parameter (see /lfx-skills:lfx-object-store-design) must be included
in the cache key — a managed policy that strips query strings (such as
the default CachingOptimized policy) will keep serving a stale object
after the version parameter changes. Use a custom cache policy that
forwards that parameter if the default managed policies exclude it.
In that cache policy, set min_ttl = 0 — Minimum TTL is the only
setting that overrides a shorter per-object max-age upward, and any
nonzero value would defeat the service's short-TTL intent for persisted
copies of the URL. default_ttl is different: it applies only when an
object carries no Cache-Control metadata at all, never overriding a
present header — keep it short (≤ 1 day) as a safety net for objects
uploaded without the metadata.
- One standardized
default_cache_behavior (GET/HEAD); no per-path
ordered_cache_behavior blocks — this keeps dynamic blocks out of the
loop.
Certificates
- One shared wildcard certificate; distribution aliases are shaped
{bucket}.{vanity-domain} (vanity domain TBD, e.g.
*.lfxuseruploads.com or *.downloads.lfx.community). This keeps ACM
issuance and DNS validation — the one real nested-loop hazard — out of
the per-bucket loop.
- The certificate must be in
us-east-1 (CloudFront requirement).
DNS
- The alias and certificate alone do not publish the hostname: each
{bucket}.{vanity-domain} name needs Route 53 alias A and AAAA
records pointing at its CloudFront distribution, in the vanity
domain's hosted zone. Enable IPv6 on the distribution
(is_ipv6_enabled = true — it defaults to false) so the AAAA record is
actually served. This is a per-public-bucket 1:1 resource — include it
in the same filtered public-bucket loop as the distribution. Without it
the CDN_URL_PREFIX handed off below will not resolve.
Write access (IRSA)
- Service access via the existing IRSA mechanism: an entry in
iam-service-account-definitions.yaml with an inline policy scoped to
the service's bucket ARN(s), reconciled through
modules/eks-service-account-role/. See
lfx-v2-opentofu/docs/service-accounts.md.
- The policy needs bucket-level
s3:ListBucket (used by the service's
HeadBucket readiness check and, if applicable, any internal listing —
see /lfx-skills:lfx-object-store-design) plus object-level
s3:GetObject, s3:PutObject, and s3:DeleteObject on
{bucket-arn}/*. Do not grant s3:CreateBucket to a deployed role —
the bucket is provisioned by this ops flow, not by the service at
startup.
- Bucket definition entries do not own IAM; keep the concerns separate.
Prohibitions
- No public-read buckets or bucket ACLs.
- No static credentials in deployed environments (IRSA only).
- No Cloudflare R2 or other non-AWS deployments. "S3-compatible" in the
design skill is app-side portability wording only.
Handoffs
After provisioning, hand these to lfx-v2-argocd environment values:
- The IRSA role ARN.
lfx-v2-argocd creates the ServiceAccount itself in
its deployment manifests, carrying the eks.amazonaws.com/role-arn
annotation; the service chart is configured with serviceAccount.create: false and serviceAccount.name to reference that externally created
account (the chart does not render or annotate the SA in this mode).
- The bucket name(s) (
S3_BUCKET, or the purpose-prefixed variants from
the design skill's multi-bucket namespacing) — one per bucket — plus the
region (AWS_REGION) once: it is process-wide, and all of a service's
buckets are provisioned in the environment's single region.
- The CDN hostname (
CDN_URL_PREFIX, e.g. https://{bucket}.{vanity-domain})
— one per CDN-fronted bucket.
Handoff boundary
The implementing agent in lfx-v2-opentofu owns the HCL, plan review, and
apply workflow under that repo's local conventions. Application-side chart
and code changes belong to the owning service repo per
/lfx-skills:lfx-object-store-design.
1---2name: lfx-object-store-ops3description: Provisioning requirements for LFX object storage backends in deployed environments (prod, staging, shared dev): private S3 buckets, CloudFront with Origin Access Control, shared wildcard certificate, and IRSA write access, all in `lfx-v2-opentofu`. Written for the linuxfoundation GitHub org's ops audience — see the `-ops` skill convention in `/lfx-skills:lfx`. Fires on prompts like "provision a bucket", "object storage backend", "CloudFront for uploads", "S3 bucket opentofu", "IRSA for S3", "object store CDN", "bucket policy", "wildcard cert uploads". Requirements only; the implementing agent owns the HCL.4---56<!-- Copyright The Linux Foundation and each contributor to LFX. -->7<!-- SPDX-License-Identifier: MIT -->89# LFX Object Store Ops1011How the object storage backend is provisioned for deployed environments.12This is a requirements stub, deliberately non-prescriptive: it captures the13constraints the Ops team has agreed to, points at the existing conventions14in `lfx-v2-opentofu`, and leaves the HCL to the implementing agent.1516Audience: linuxfoundation GitHub org members. This skill is written for17that ops audience and assumes org membership; it does not itself enforce18it (see the `-ops` skill convention in `/lfx-skills:lfx` for how the router19handles routing when membership is unconfirmed). Application-side design20(endpoints, SDK usage, chart contract, local dev) is21`/lfx-skills:lfx-object-store-design`.2223## Where2425All resources live in26[`lfx-v2-opentofu`](https://github.com/linuxfoundation/lfx-v2-opentofu):2728- Environments are OpenTofu **workspaces** (`dev`, `staging`, `prod`), not29 directories. Resource names interpolate `${terraform.workspace}` (see30 `s3.tf` for existing examples).31- Workflow via the repo `Makefile` (`make init/plan/apply`); read the repo32 `README.md` before planning.3334## Pattern3536Data-driven flat `for_each`, matching the postgres convention37(`postgres-database-definitions.yaml` + `postgres.tf`). **No new module** —38the Ops team prefers flat loops over YAML definitions; modules are reserved39for cases that would otherwise require nested loops (see40`modules/eks-service-account-role/`).4142- New `object-store-definitions.yaml` holding one entry per service bucket,43 including a boolean flag marking whether the bucket is public44 (CDN-fronted) — the design skill requires public and private files in45 separate buckets, so this is a per-bucket property, never mixed.46- New `object-storage.tf` with one `for_each` per resource type. Bucket47 resources loop over every entry: `aws_s3_bucket` (+ versioning, SSE,48 lifecycle, public-access-block). CDN resources — `aws_cloudfront_origin_access_control`,49 `aws_cloudfront_distribution`, `aws_s3_bucket_policy` (OAC read policy),50 and the DNS alias records — loop over a filtered51 `{ for k, v in local.object_stores : k => v if v.public }` so a52 distribution is only ever attached to explicitly public buckets.53 Attaching CloudFront/OAC to a service-API-only bucket would make its54 private objects anonymously retrievable. Still flat 1:1 loops, no55 nesting.5657## Requirements5859### Buckets6061- **Private only.** Public access block enabled; no public-read bucket62 policies. The existing `lfx-one-project-logos-png-*` buckets in `s3.tf`63 are a legacy anti-pattern — do not copy them.64- Versioning and SSE enabled; names interpolate `${terraform.workspace}`.65 Bucket versioning here is a data-protection feature (recovery from66 accidental delete/overwrite) — it is unrelated to the design skill's67 cache-busting query parameter (`/lfx-skills:lfx-object-store-design`,68 "Download flow"), which is an app-generated hint in `public_url`, not the69 S3 `VersionId` this setting produces. Do not conflate the two: enabling70 bucket versioning does not, by itself, invalidate CDN caches or change71 what the cache-busting parameter needs to look like.72- **Lifecycle rules are mandatory alongside versioning.** With versioning73 on, `DeleteObject` only adds a delete marker and every overwrite retains74 the prior payload as a noncurrent version — without lifecycle75 expiration, storage grows unboundedly and user-"deleted" content is76 retained indefinitely (a data-retention problem, not just a cost one).77 Each bucket gets an `aws_s3_bucket_lifecycle_configuration` (1:1, same78 flat loop) that: expires noncurrent versions after **30 days**, removes79 expired object delete markers, and aborts incomplete multipart uploads80 after **7 days**.8182### CloudFront8384- **Origin Access Control (OAC)** with a `SourceArn`-conditioned bucket85 policy. Not the legacy OAI (`aws_cloudfront_origin_access_identity`) or86 `forwarded_values` patterns — the ITX87 `cloudfront-distribution-with-s3` module is a structural reference only88 and must not be copied (deprecated OAI, `forwarded_values`, TLSv1).89- Managed cache and origin-request policies that honor the per-object90 `Cache-Control` header. This header is **object metadata set by the91 service at upload** (`PutObject`'s `CacheControl` field), not an origin92 or bucket configuration — the design skill's baseline is a short TTL,93 `public, max-age=86400`, since the cache-busting parameter is a hint94 rather than an immutable version. The design skill's cache-busting query95 parameter (see `/lfx-skills:lfx-object-store-design`) must be included96 in the cache key — a managed policy that strips query strings (such as97 the default `CachingOptimized` policy) will keep serving a stale object98 after the version parameter changes. Use a custom cache policy that99 forwards that parameter if the default managed policies exclude it.100 In that cache policy, set **`min_ttl = 0`** — Minimum TTL is the only101 setting that overrides a shorter per-object `max-age` upward, and any102 nonzero value would defeat the service's short-TTL intent for persisted103 copies of the URL. `default_ttl` is different: it applies only when an104 object carries no `Cache-Control` metadata at all, never overriding a105 present header — keep it short (≤ 1 day) as a safety net for objects106 uploaded without the metadata.107- One standardized `default_cache_behavior` (GET/HEAD); no per-path108 `ordered_cache_behavior` blocks — this keeps dynamic blocks out of the109 loop.110111### Certificates112113- One shared **wildcard certificate**; distribution aliases are shaped114 `{bucket}.{vanity-domain}` (vanity domain TBD, e.g.115 `*.lfxuseruploads.com` or `*.downloads.lfx.community`). This keeps ACM116 issuance and DNS validation — the one real nested-loop hazard — out of117 the per-bucket loop.118- The certificate must be in `us-east-1` (CloudFront requirement).119120### DNS121122- The alias and certificate alone do not publish the hostname: each123 `{bucket}.{vanity-domain}` name needs **Route 53 alias A and AAAA124 records** pointing at its CloudFront distribution, in the vanity125 domain's hosted zone. Enable IPv6 on the distribution126 (`is_ipv6_enabled = true` — it defaults to false) so the AAAA record is127 actually served. This is a per-public-bucket 1:1 resource — include it128 in the same filtered public-bucket loop as the distribution. Without it129 the `CDN_URL_PREFIX` handed off below will not resolve.130131### Write access (IRSA)132133- Service access via the existing IRSA mechanism: an entry in134 `iam-service-account-definitions.yaml` with an inline policy scoped to135 the service's bucket ARN(s), reconciled through136 `modules/eks-service-account-role/`. See137 `lfx-v2-opentofu/docs/service-accounts.md`.138- The policy needs bucket-level `s3:ListBucket` (used by the service's139 `HeadBucket` readiness check and, if applicable, any internal listing —140 see `/lfx-skills:lfx-object-store-design`) plus object-level141 `s3:GetObject`, `s3:PutObject`, and `s3:DeleteObject` on142 `{bucket-arn}/*`. Do **not** grant `s3:CreateBucket` to a deployed role —143 the bucket is provisioned by this ops flow, not by the service at144 startup.145- Bucket definition entries do **not** own IAM; keep the concerns separate.146147### Prohibitions148149- No public-read buckets or bucket ACLs.150- No static credentials in deployed environments (IRSA only).151- No Cloudflare R2 or other non-AWS deployments. "S3-compatible" in the152 design skill is app-side portability wording only.153154## Handoffs155156After provisioning, hand these to `lfx-v2-argocd` environment values:157158- The IRSA role ARN. `lfx-v2-argocd` creates the ServiceAccount itself in159 its deployment manifests, carrying the `eks.amazonaws.com/role-arn`160 annotation; the service chart is configured with `serviceAccount.create:161 false` and `serviceAccount.name` to reference that externally created162 account (the chart does not render or annotate the SA in this mode).163- The bucket name(s) (`S3_BUCKET`, or the purpose-prefixed variants from164 the design skill's multi-bucket namespacing) — one per bucket — plus the165 region (`AWS_REGION`) once: it is process-wide, and all of a service's166 buckets are provisioned in the environment's single region.167- The CDN hostname (`CDN_URL_PREFIX`, e.g. `https://{bucket}.{vanity-domain}`)168 — one per CDN-fronted bucket.169170## Handoff boundary171172The implementing agent in `lfx-v2-opentofu` owns the HCL, plan review, and173apply workflow under that repo's local conventions. Application-side chart174and code changes belong to the owning service repo per175`/lfx-skills:lfx-object-store-design`.