@cfxlabsinc/ecrPulumi-managed ECR repositories and pull-through cache for CFX container apps.
Per-app repositories — one ECR repository per container app (utila-cosigner, admin-dashboard, temporal-worker, internal-dashboard) per env, with:
scanOnPush enabledThe single source of truth for which repos exist is
pulumi/repos.ts. Adding a new container app means appending
a name there and running pulumi up against each env stack (or letting the
ecr-pulumi-cd-{dev,prod} job in deploy-env.yml do it on merge to main).
ECR Public pull-through cache — an aws.ecr.PullThroughCacheRule per env
with ecrRepositoryPrefix: "ecr-public" proxies public.ecr.aws (AWS's
public Docker Hub mirror). Our Dockerfiles accept a BASE_IMAGE_PREFIX build
arg; CI sets it to <account>.dkr.ecr.us-west-2.amazonaws.com/ecr-public/docker/library/
so e.g. node:24-bookworm-slim resolves through the in-VPC ECR cache instead
of crossing NAT to Docker Hub. First pull populates the cache lazily;
subsequent builds hit ECR directly. Local nx image-build leaves the prefix
empty and pulls from Docker Hub directly.
Why ECR Public and not Docker Hub directly: AWS rejects unauthenticated
Docker Hub pull-through with UnsupportedUpstreamRegistryException. ECR
Public is anonymous-friendly, AWS-managed, in-region, and mirrors every
Docker official library image at public.ecr.aws/docker/library/<image> —
which is what we use for node and alpine. No Secrets Manager hop, no
credentials to rotate.
Repos and cache rules live in the same workload account that builds and
consumes them (206248878611 for dev, 372806568664 for prod). No
cross-account pull policy is needed — ECS tasks and Lambda functions read
images from the same account they run in.
Until this package existed, ECR repos were created and re-asserted on every CI
build by the .github/actions/create-ecr-repo composite action via raw
aws ecr {create-repository,set-repository-policy,put-lifecycle-policy} calls.
That left repo policy + lifecycle drift impossible to detect, and made changes
land asynchronously (only on the next CI run that happened to invoke the
action). Owning it in Pulumi makes the policy diff-able and reviewable.
Two stacks — one per env:
| Stack | Account | Backend |
|---|---|---|
dev |
206248878611 |
s3://cfx-pulumi-iac-backend-dev?region=us-west-2 |
prod |
372806568664 |
s3://cfx-pulumi-iac-backend-prod?region=us-west-2 |
Each stack provisions its repos in its own workload account via the
cfx-role-devops OIDC role already trusted by the container CI/CD workflows.
No manual bootstrap. cd-pulumi.yml invokes pulumi up --upsert, so the first
CD run per env creates the stack from Pulumi.<stack>.yaml. The Repository
resources have the import: option set, so the first apply adopts the existing
repos that .github/actions/create-ecr-repo/ previously created imperatively
— it doesn't try to re-create them.
Wired into deploy-env.yml as ecr-pulumi-cd-{dev,prod} jobs, gated by
nx affected on packages/ecr/**. The imperative create-ecr-repo is still
in .github/actions/container-build-push/action.yml as a belt-and-suspenders
safety net (no-op once the repo exists) — drop it in a follow-up PR after both
env applies are confirmed green at least once.
Lifecycle policies adopt via standard pulumi create on first apply (there's no
prior pulumi state of them to import; the imperative action set them via
aws ecr put-lifecycle-policy, which pulumi can overwrite cleanly).
Before this rework, a single cicd stack lived in account 764105176280 and
emitted a cross-account pull policy granting 206248878611 and 372806568664
read access. The CICD account is being decommissioned — repos and their
consumers now share an account per env, so the cross-account dance is gone.
If the old cicd stack was ever applied, the cfx-<app> repos in
764105176280 are orphaned by this change. Decide whether to delete them or
leave them as the source for one final migration push, then clean up the
stack:
cd packages/ecr/pulumi
pulumi login s3://cfx-pulumi-iac-backend-cicd?region=us-west-2 # if it ever existed
pulumi stack rm cicd