Skip to main content

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 .dockerignore pattern 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 .dockerignore change 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 .dockerignore for a new repo, run du -sh */ (and du -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 .gitignore listing dist, pnpm deploy --prod --legacy will silently exclude the freshly-built output. Switch to an unpruned full copy instead of fighting it.
  • Read the service's live buildCommand/startCommand/ preDeployCommand via the Render API before writing a Dockerfile — render.yaml drifts, 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 no preDeployCommand hook (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=false and 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 explicit POST /deploys is required every time.
  • If another session might be touching the same repo, check git branch --show-current before 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-named env-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 .dockerignore at all, so its explicit COPY steps would bake in secrets a real git clone never has.
  • A stale local *.tsbuildinfo file (gitignored, so a real clone never has one, but a dev machine's disk does) can make tsc --build believe 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 **/*.tsbuildinfo in .dockerignore alongside **/dist.
  • When one repo has multiple Render services built from the same buildCommand base, give the shared build stage named final targets (--target runtime-web vs --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 prefix crn-) use the identical PATCH/POST /deploys API 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-process env: option in the wrapper, never a process.env mutation — 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.
  • 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.