docker-ecr-render-migration
Why It Exists
The skill was written immediately after a real rollout — now spanning 20+
repos and every Render service type (web APIs, background workers, and
cron jobs) — in one personal portfolio, documented in
cates-works/docs/sessions/2026-08-22-ecr-docker-migration-rollout/. The
migration itself was motivated by Render's GitHub App integration being
observed to silently drop its installation credentials, breaking
auto-deploy with no visible error — the same class of failure that had
already caused an incident referenced in an earlier account-split effort
this rollout builds on.
The rollout's own LESSONS.md records the finding that shaped this
skill's single most emphasized rule: every .dockerignore written before
it was caught used bare patterns (.env*, node_modules), which only
match at the Docker build context root, not recursively — unlike
.gitignore's default behavior. Every real secret file in every real repo
lived one directory down (apps/api/.env.prod.local), so the exclusion
had been silently doing nothing. ADR 0002 in that same dossier documents
the incident: real DATABASE_URLs, API keys, and access tokens were baked
into pushed ECR images across at least four repos (cates-works,
offerly, workloom-baro-x, work-loom) — including images pushed
independently by a second, concurrent session working the same repos, who
hit the identical bug with no coordination between the two sessions. The
account owner was presented the full exposure assessment and explicitly
decided against credential rotation given the contained (private-ECR-only)
blast radius — a decision this skill's history preserves rather than
re-litigates.
Two more real bugs from that same rollout became the skill's other load-
bearing rules. LESSONS.md documents pnpm deploy --prod --legacy
silently dropping a freshly-built dist/ directory in two repos
(cogs-send-service, workloom-baro-x) because each package had its own
local .gitignore listing dist — pnpm respects that file when copying,
regardless of build freshness (tracked upstream as pnpm/pnpm#7286). And in
poker-helper (branded "dealnight-api" on Render), a literal port of the
old deploy command's DATABASE_URL="$DIRECT_URL" shell trick into a
Node env override was caught by boot-testing before it ever reached
production — the repo's own prisma.config.ts already implemented the
same fallback, and the naive port would have silently overwritten a real
DATABASE_URL with undefined whenever DIRECT_URL happened to be
unset.
What It Does
Migrates one repo's API service from Render's git-backed deploy to a
Docker image on a private AWS ECR registry, with Render pulling that image
directly instead of building from source. The AWS account, IAM policies,
OIDC role, and a shared credential-refresh Lambda (ecr-render-cred-refresh,
which keeps every Render workspace's registry credential ahead of its
12-hour AWS token expiry) already exist for this portfolio — this skill
is explicitly the per-repo instance of an already-decided pattern, not a
from-scratch infrastructure decision each time.
It walks through: reading the service's live Render config (not
render.yaml, which drifts) to find any migration step hiding in
buildCommand/startCommand; checking real repo size before writing
.dockerignore, since an oversized Terraform provider cache or a native
mobile app tree produces the exact same symptom as the recursion bug but
needs a completely different fix; choosing among three Dockerfile shapes
depending on whether the target package can safely use pnpm deploy
pruning; moving any migration step into a container-entry wrapper that
runs before the server starts, never baked into the image build itself;
and building for linux/amd64 with attestations disabled, since an
arm64-plus-attestation-manifest image produces a Render pull error that
reads exactly like a credential failure.
How To Use It
Triggers on: "move X to ECR", "dockerize the API for X", "cut X over to
Docker", "render 0N and X-api", any request naming a repo plus a
RENDER_0N/RENDER_NN account number in the same breath, or a report
that a service's git-backed Render deploy is flaky.
skills add git@github.com:catesandrew/next-starters.git --skill skills/docker-ecr-render-migration -g
npm install @next-starters/skill-docker-ecr-render-migration
/plugin marketplace add catesandrew/next-starters
/plugin install docker-ecr-render-migration@next-starters
Gotchas & Invariants
- Every
.dockerignorepattern needs a**/prefix. A bare pattern only matches the build context root — the rule the whole rollout learned the hard way, repeatedly, because a bare pattern "looking like it works" in one repo's specific directory layout doesn't mean it's actually depth-agnostic. - Verify a
.dockerignorechange by extracting the real image (docker create+docker cp+find -iname ".env*"), not by reading the pattern and reasoning about whether it looks right — it will look right regardless. - Before writing
.dockerignorefor a new repo, rundu -sh */(anddu -sh apps/*/if relevant). Twice in this rollout a slow build turned out to be a domain-specific oversized directory (a Terraform provider cache, a native mobile/TV app tree) that the standard exclude list had never needed to cover before — not the recursion bug. - If the target package (or a dependency it needs at runtime) has its own
local
.gitignorelistingdist,pnpm deploy --prod --legacywill silently exclude the freshly-built output. Switch to an unpruned full copy instead of fighting it. - Read the service's live
buildCommand/startCommand/preDeployCommandvia the Render API before writing a Dockerfile —render.yamldrifts, and this portfolio has at least one repo where a second live service was never declared in it at all. - Any DB migration living in the old deploy pipeline moves into a
container-entry wrapper that runs before the server starts — a
distroless/slim runtime has no shell for
&&, and Render's image-based deploy type has nopreDeployCommandhook (only git-backed Docker deploys get one). - When porting an old deploy command's env-var handling into that wrapper,
check whether the app's own config already implements the same fallback
first — replicating it a second time can silently overwrite a real
value with
undefined. - Always build with
docker buildx build --platform linux/amd64 --provenance=false --sbom=falseand verify the manifest (docker buildx imagetools inspect) before attempting a cutover — the generic Render pull error this prevents looks identical to a credential failure and cost real debugging time the first time it appeared. - Render does not auto-redeploy an image-backed service on a new image
push to the same tag, or even right after the config
PATCH— an explicitPOST /deploysis required every time. - If another session might be touching the same repo, check
git branch --show-currentbefore any git operation — this rollout had a working directory silently switched to an unrelated branch mid-task by a concurrent process. - Secrets don't only hide in dotfiles — an
env-mirror.mjs-style script mirroring real secrets into a differently-namedenv-mirrors/directory leaked into a build the.env*pattern never caught. And a repo already having a working Dockerfile doesn't mean it's safe: one repo was already git-backed Docker with zero.dockerignoreat all, so its explicitCOPYsteps would bake in secrets a real git clone never has. - A stale local
*.tsbuildinfofile (gitignored, so a real clone never has one, but a dev machine's disk does) can maketsc --buildbelieve a workspace dependency is already compiled and silently skip it — the build step reports no error; the missing module only surfaces at boot-test time. Exclude**/*.tsbuildinfoin.dockerignorealongside**/dist. - When one repo has multiple Render services built from the same
buildCommand base, give the shared build stage named final targets
(
--target runtime-webvs--target runtime-worker) instead of rebuilding per service — push the same local image to each service's own ECR repo. - Cron jobs (
type: cron_job, id prefixcrn-) use the identicalPATCH/POST /deploysAPI surface as web/worker services — no separate endpoint. Don't manually trigger a real run to "verify" a cron job works if it has an external side effect (a Slack post, an email); a clean deploy status plus a boot-tested clean failure is enough. - When a live buildCommand does
VAR=x command(e.g. a migration needing a direct, non-pooled DB connection while the app itself needs the pooled one), replicate it as a child-processenv:option in the wrapper, never aprocess.envmutation — the latter leaks into whatever gets imported afterward. - Not every migration ends fully green, and that's the correct outcome to report. One background worker started correctly, then got an external SIGTERM about a minute in with no crash logged and no restart — confirmed it wasn't the app's own code, wasn't workspace-wide, and reproduced identically across two deploys, then stopped investigating and told the user plainly rather than guessing further or quietly calling it done.
Related Skills
- prod-deploy — the equivalent "first production deploy" checklist for a repo that hasn't migrated to image-backed Render at all yet; this skill is what to reach for once a repo already has a working git-backed deploy and needs to move off it.
- runtime-env-config — the app-side env loader this skill's containers still depend on at runtime; Docker packaging doesn't change how a repo resolves its own env vars, only how the image ships.
Sourced from: skills/docker-ecr-render-migration/metadata.json,
skills/docker-ecr-render-migration/SKILL.md,
cates-works/docs/sessions/2026-08-22-ecr-docker-migration-rollout/
(SUMMARY.md, LESSONS.md, ARCHITECTURE.md, adr/0001, adr/0002) — the
session dossier this skill was extracted from, written the same day as
the rollout it documents.