You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tracking issue for the production hosting, DNS and content migration. The authority is the runbook — _docs/runbooks/production-hosting-and-dns-migration.md. This issue is the tick-off surface; the runbook holds the detail, the rationale and the verification for every line below. Section references (§) point into it.
What this migration does.datatalks.club moves off GitHub Pages and courses.datatalks.club moves off CMP, both onto this Django deployment, with DNS moving from GoDaddy to Route 53 and email moving to Relay in the main AWS account.
Markers.[P] needs production AWS credentials (387546586013) · [G] needs GoDaddy console access · [OW] one-way or expensive to reverse · [W:x] hard wait of duration x · [B:x] blocked on x.
Lanes.MAIN is the apex/DNS critical path. COURSES, EMAIL, DEV and CLEANUP run in parallel with MAIN and with each other — only the order within a lane is strict, plus the named cross-lane joins.
Companion design documents (linked from the runbook, not restated in it): _docs/design/specs/data-migration-architecture.md · _docs/design/specs/unit-content-pipeline.md · _docs/design/specs/script-inventory.md · the costed Terraform design for website prod/dev + Relay prod/dev, currently at .tmp/infra-design-dev-prod-relay.md and outside version control (D36).
Decisions
Resolved (§3.1):
D1 — two-stage apex swap: apex serves the static site from S3 first, then swaps to the Django site
D2 — minimal shared dev.; the sandbox account is retired
D3 — two distinct Lambdas on courses. (maintenance, then redirect)
D13 — Relay is the sender, not Datamailer
D18 — one curriculum layout: cohorts/<year>/<module>/module.yaml. llm-zoomcamp already conforms (7 modules); machine-learning-zoomcamp (9) and ai-dev-tools-zoomcamp (4) move. ⚠️This reverses the earlier advice to revert llm-zoomcamp@c04db93 (_docs/runbooks/session-handoff-20260902.md:53-55) — under D18 that commit is correct and llm-zoomcamp/cohorts/README.md is what is wrong. No public URL moves.
D19 — modules-format rollout covers every course except stock-markets-analytics-zoomcamp, which stays legacy for 2026. data-engineering-zoomcamp is a candidate to assess, not a commitment.
D20 — convention over configuration: unit/page structure is consistent across courses and derived by convention, not declared per course
D21 — DataTalksClub/zoomcamp-template owns that convention (STRUCTURE.md, docs/conventions.md, templates/{root,module,cohort}-README.md). Any layout change includes that repository.
D22 — dev.datatalks.club carries fake data, never production-derived. Dev Relay therefore never sees a real address, which is what makes the shared dev database low-stakes.
D23 — Datamailer is the source of Relay's suppression state, and copying it is a precondition of the first production send, not Phase 7 cleanup. Consequence: sandbox Datamailer must survive until that copy is done.
D24 — no homework slug rewrites; the ^hw(\d+)$ → homework-0N transform is removed, explicit overrides only
D25 — six cohorts stay skipped: ai-bootcamp-2025, ai-hero-2025, ai-hero-2026, sma-zoomcamp-2026, ai-buildcamp-2, ai-buildcamp-3. Four need only a mapping entry; ai-buildcamp-2/-3 carry edition numbers, not years, which the family+year model cannot express — that pair needs design.
D26 — the cost bar is website-only. Website (prod + dev) ≤ CMP; Relay is a separate budget.
D27 — the SEO impact of the apex /docs/, /faq/, /podwiki/ redirect is accepted; no action (also recorded at C3/C4/C5 below)
Open (§3.2) — each one gates the steps that cite it:
D4 — cache freshness strategy (still unresolved, and it gates the apex swap — see the Phase 5 note)
D5 — media key scheme
D6 — zone root placement
D7 — registrar transfer (deferred)
D8 — courses-zone fold-in
D9 — RDS Multi-AZ timing — gates 4.1; single-AZ is what the $169.98 Budget A assumes
D10 — SPF strategy
D11 — git history rewrite
D12 — direct-sync source manifest
D14 — SES identity owner ⚠️on the EMAIL critical path
D15 — Relay sandbox data (clean start recommended; superseded in part by D23 for suppression state)
D16 — Luma-exporter home
D17 — Luma refresh owner and cadence
D28 — SITE.md rollout shape. Two incompatible implementations in flight: one committed to local main in three repos (short copy, deletesdescription_path), one as six PRs from origin/main (551–635-char copy from content/docs_projection.json, repointsdescription_path). The website side has already landed and decides part of it — course.yaml no longer accepts description_path at all, so the repointing lane would be rejected with unknown_key. Recommended default: the first lane's convention with the second lane's copy.
D29 — dev-database placement: dedicated db.t4g.micro (≈$15.68/mo) or logical databases on the production instance ($0). Recommended: dedicated.
D30 — Relay production database size: db.t4g.medium (≈$52.56) or db.t4g.small (≈$26.28). Recommended: medium.
D31 — real transactional email volume, and the real newsletter cadence (weekly vs the 5–7/month stated to AWS)
D33 — when main/dtc-website joins the policy scan (adding it converts an existing grant into a violation). Recommended: after the apex swap, sequenced with D4.
D34 — extract modules/relay-host or duplicate two Relay roots. Recommended: extract.
D35 — retire the main/common bastion in favour of SSM Session Manager (−$6.70/mo, removes a 24/7 SSH-exposed host)
D36 — give the costed infrastructure design a home in version control
Phase 0 — Inventories and prerequisites
MAIN · read-only · §5
0.1 [G] Export the complete GoDaddy zone, every record, including a _domainkey sweep
0.2 [G] Record the www and join forwarding configurations
0.3 [P] Pull the 3-month Cost Explorer baseline
0.4 [P] Identify and record the courses hosted zone (Z00653771…)
0.5 Owner settles open decisions D4–D10
0.6 [P] Record the live SES sending quota and maximum send rate — same step as E.1. Production access already exists and the quota has been raised above the default, so this is a verification step, not a blocker. Two numbers, not one: Max24HourSend must clear ~130,000 recipients in a day alongside transactional traffic, and MaxSendRate decides whether a 130,000 campaign takes ~2.6 h (at 14/s) or ~11 min (at 200/s). If either falls short, the increase is a support request with multi-day lead time and should be raised at once.
1.1 [P][B:D6,D10] Create the main/dns zone; byte-copy every record from 0.1 — ⚠️written, in main/dtc-website/dns.tf rather than a separate main/dns root (304189f, 73d3364). Note the zone was created while D6 is still open, and the apex record still points at GitHub Pages rather than the new distribution. Apply status not determinable from the repo.
1.2 [P]join replacement: API Gateway custom domain, with the ACM CNAME added at GoDaddy now
1.3 www strategy — build the Phase 2 distribution first (recommended)
1.5 [G][OW-ish] NS cutover at the registrar; record freeze; mail checks at +1 h and +24 h [W:48h]
1.6 Update stale DNS claims in the aws-infra docs
Phase 2 — Apex to S3/CloudFront (stage 1 of the D1 swap)
MAIN · needs Phase 1 · §7 · ✅ EXECUTED 2026-09-02, with a design change
Only the main site tree was rehosted into S3. /docs/, /faq/ and /podwiki/ were not rebuilt — a CloudFront viewer-request Function now 302s them to datatalksclub.github.io. Verified live: x-cache: FunctionGeneratedResponse from cloudfront.
Tree
source_id
Baseline rows
Now
Main site
dtc-main-site
2,301
Served from S3 (200)
Docs
dtc-docs
174
302 → github.io
FAQ
dtc-faq
152
302 → github.io
Podwiki
dtc-podwiki
310
302 → github.io
636 rows — 21.7% of the compatibility contract — now redirect instead of returning 200.
2.1 Rebuild the pinned legacy trees — done for dtc-main-site; the other three are no longer rebuilt at all
2.2 [P] Bucket + OAC + resolver function + distribution — landed in main/dtc-website/legacy_static_site.tf (038d4c3, 3d66411, 580ef4b) rather than a separate main/legacy-site root. ⚠️ That merge complicates step 7.3, which assumes the legacy site can be retired independently.
2.3 [P] Upload the main tree
2.5 [P] Apex alias swap — done, apex now answers from CloudFront/S3
Follow-ups this created
C1 — the compatibility contract no longer describes reality. All 636 redirected rows carry classification: "preserve" / expected_status: 200. The baseline schema hard-codes const: "preserve", so a redirect cannot be expressed in it — only through the digest-bound approved-expectations sidecar. Until that is recorded, the 2.4 parity crawl reports 636 regressions and cannot function as a gate — not for this phase, and not for the 9.4 stage-2 swap that depends on it. Highest priority in the phase.
2.4 [B:C1] Staging parity crawl — rescope to the 2,301 S3-served rows plus redirect assertions for the other 636
C2 — GitHub Pages is now a permanent production dependency, not a rollback net. docs, faq and podwiki must stay live and maintained indefinitely — the apex holds no copy of their content. This invalidates 2.6 ("frozen for ≥30 days") and 7.2 ("retire the four Pages deployments") for three of the four.
C3/C4/C5 — SEO impact accepted; no action (D27). Owner decision, 2026-09-02: these pages are not meaningfully indexed, so the redirect's search consequences do not need mitigating. This closes three findings together — the missing rel=canonical on the FAQ tree (152 rows; docs and podwiki do carry one), the 302-vs-301 question, and the apex robots.txt still advertising Sitemap: https://datatalks.club/podwiki/sitemap.xml which now redirects off-domain. Recorded so they are not re-raised. Note this does not touch C1 or C2, which are contract and availability problems rather than search problems.
C7 — the redirect must survive the stage-2 swap. When the apex alias moves to the Django site (4.4), these 636 paths break unless the new distribution carries the same Function or Django answers them itself. Added to the 4.4 gate below.
2.6 Keep the main-site Pages deployment live and frozen ≥30 days as the rollback target
3.2a Resolve the confirmed orphan media file (1,254 files against 1,253 records) — identified as images/podcast/s24e06-how-to-build-ai-that-actually-ships-in-production.jpg, unrecorded on disk and correctly absent from the bucket; still needs a decision on whether to record it or delete it
3.3 [P]media-sync initial upload — done; all 1,253 objects are present in s3://dtc-website-media/
3.4 [P]media-verify: 1,253/1,253 digest matches plus a two-way set reconciliation
1,253/1,253 verified by fetching every recorded key through CloudFront and re-digesting the bytes — zero unreachable, zero digest mismatches, zero content-type mismatches. Both space-containing keys return 200.
two-way set reconciliation — needs s3:ListBucket, so it cannot be done anonymously; there is currently no proof the bucket holds no extra objects
idempotency unproven by execution — a second media-sync run should report 0 uploads, but that is inferred from reading content/media_tooling.py:285, not observed
3.5 Wire the media store into the deployed task definitions (57fc3ae); the /images/* CloudFront behavior itself still waits on the Phase 4 distribution
Phase 4 — Production stack, prod. and dev., stage-2 swap
MAIN + DEV · §9
4.0 [P]Policy-suite amendment first — credential-free and the longest pole. Three fail-closed blockers, all verified in ~/git/aws-infra (§14.6):
SOURCE_DIRECTORY_SCOPES is a literal four-element tuple (tests/policy/safe_repository_inputs.py:16-20) and contains none of the new roots; adding one drags in EXPECTED_TERRAFORM_CONFIG_FILES, the source manifest, and the pinned test counts
a production root under the DataTalksClub owner cannot pass OIDC004/OIDC005 — both force exact sandbox claim values today
aws_iam_instance_profile has zero permitted owners, so Relay cannot enter scope at all
(D33) adding main/dtc-website converts its existing cloudfront:CreateInvalidation grant (legacy_static_site.tf:459-469) into a violation — defer until after the apex swap
while in there: main/maintenance-page has no enable_deployment gate, no backend block, no allowed_account_ids, no tags — an apply at defaults stands up a live public HTTP API
4.1 [P][B:D9] Production website root — module instantiation, with policy-suite churn budgeted (§9.1). Root naming still open between main/dtc-website and main/website; the costed design recommends main/website.
4.2 [P] Minimal dev.datatalks.club on shared production infrastructure; tradeoffs accepted under D2 (§9.2) and made low-stakes by D22 (fake data)
4.3 Staging discipline while prod. and the apex both serve: four duplicate-content controls, prod. noindexed (§9.3)
4.4 [P][OW-SEO] Stage-2 apex swap (§9.4), after a six-item gate:
parity on prod. against the live apex
M8 steps 1–4 complete
caching evidence captured
rollback rehearsed — including the three-step alias procedure below
data freshness: sources re-synced within 72 h and at least N future-dated events (§13.8)
full-fidelity CMP import proven against a disposable target (§13.9) — ⚠️currently unbuilt
the /docs/, /faq/, /podwiki/ → github.io redirect survives the swap (C7) — either the new distribution carries the same CloudFront Function, or Django answers those 636 paths itself
⚠️The DNS change alone is not sufficient. CloudFront refuses an alias another distribution already claims, and both apex names are claimed by the legacy distribution (legacy_static_site.tf:269) — a second distribution gets CNAMEAlreadyExists. Order: aws cloudfront associate-alias onto the new distribution → flip the Route 53 aliases → then remove the names from the legacy distribution's aliases. Doing the last step first turns a rehearsed swap into an outage. Alias rollback is 1–10 min; keep the S3 legacy site warm ≥30 days.
Phase 5 — Anonymous edge caching
MAIN · must be proven before 4.4 · §10
5.i [website] Route-cache registry, cache-class headers, and no-Set-Cookie/CSRF tests
5.iii [B:D4] Reviewed narrow invalidation grant for the worker role — after or alongside the direct-sync work
⚠️ §10.4 records a hard contradiction between the owner's "update the cache on updates" requirement and the standing ban on cloudfront:CreateInvalidation. Still unresolved, and it gates the apex swap. It needs resolving, not working around — and it is entangled with D33, because main/dtc-website already holds a scoped invalidation grant that only stays legal while that root is outside the scan.
Phase 6 — courses.datatalks.club, two Lambdas
COURSES lane · fully parallel, the zone is already on Route 53 · §11
6a.1 Fix the maintenance Lambda: 200 → 503 with Retry-After; track the root, add a backend — the main/maintenance-page root now exists (fcdcdbf) but still returns 200 (lambda/index.js:433-439), and 🚨 unlike main/courses-redirect it has no enable_deployment gate at all — every resource is unconditional, so an apply today creates live resources. "Inactive" here means only that nobody has applied it.
6a.2 [P] Deploy dark with a certificate; rehearse the alias swap on dev.courses.datatalks.club
6a.3 [P] Activate during the M8 freeze (rollback: revert the alias, rehearsed)
6A′ [P] Compatibility views on the new stack — APIs must never cross-host redirect
6c.1 [P]main/courses-redirect: generated path map, 301 on GET/HEAD, gated 308 — root written (fcdcdbf) and genuinely inert: every resource carries count = var.enable_deployment ? 1 : 0, defaulting to false
6c.2 [P][B:consumer gate] Redirect Lambda live; CMP infrastructure retained through its window
Phase E — Email via Relay
EMAIL lane · parallel, does not wait for DNS · §11E
Volume, and what it changes. 130,000 newsletter recipients weekly plus transactional ≈ 563,000 messages/month. Consequences already folded into the runbook: SES postage alone is ≈$56/mo (≈$56–91 depending on the real cadence — D31); delivery history grows 0.65–1.04 GB per weekly campaign, 34–54 GB/year unpruned; and §14's old "$15–18/mo for all of main/relay" was wrong by roughly 8× — Relay production is ≈$144/mo.
E.1 [P] SES quota and send rate recorded — the same step as 0.6. Not a blocker: production access exists (aws-infra/docs/aws-support/2026-08-09-ses-newsletter-quota-increase.md:19) and the quota is raised. That document records the 2026-08-09 state (50,000/24 h, 14/s) and two open increase requests to 200,000/24 h and 200/s; its own follow-up to record the approved values was never done. Do it here.
E.3 [P][B:D14 hand-off]main/relay root:
bind to the datatalks.club identity and a Relay-owned configuration set
outbound only — inbound stays unconfigured, which is the key mitigation from the code audit
--memory limits set for the 1 GiB host
managed RDS db.t4g.medium, single-AZ, gp3 50 → 500 GB (D30) — not PostgreSQL on the host as sandbox does
retention/pruning job shipped in the same change — prune_db_task_results exists and has never been invoked; RDS storage autoscaling is one-way, so this is a go-live prerequisite, not an open milestone
backups and one rehearsed restore are part of done. Note C3 ("no backup") is not a defect — Relay has never had a client, the website is its first, so backups/pruning/volume monitoring are capabilities that were never required and now become requirements
observability on the django-website injected-alarm contract, decided before the Terraform is written
deploy dark
E.4 Clean-start the data volume (D15); the sandbox Relay stays on as the dev Relay
E.4a Copy Datamailer's suppression and unsubscribe state into Relay (D23) — a precondition of the first production send, before E.6 stage 1. An empty suppression list re-mails exactly the addresses that already damaged the sending reputation. Fallback if the export is impractical: seed SES account-level suppression. Keeps sandbox Datamailer alive until verified.
E.5 GATE — both assessments delivered. [B:remediation] — the code-audit verdict is "not ready to send production email":
E.5a minimum fix list C1–C4 plus H2 and H5, via groomed issues
E.5b observability items 1–9 (items 1 and 1b are under a day's work and get detection to 15 minutes)
E.6 [B:E.5a] Ramp: a stage −1 shadow week running dry_run at volume, then stages 0–4. What constrains the ramp is deliverability evidence — bounce and complaint rates — not sending capacity. Hard preconditions: the canary and outbox_* alarms re-pointed at Relay with a live subscriber proven; suppression state copied (E.4a) before stage 1; pruning proven before stage 4. Abort thresholds: 3% bounce, 0.08% complaint.
E.7 Datamailer stays read-only; fixes the 7.4 teardown ordering trap
Phase 7 — Decommission and cost harvest
CLEANUP · after every rollback window has closed · §12
7.0 [P] SES identity state mv out of main/cmp — execute early, together with E.3
7.0a [P] Rehome the email canary (health probe and alarms) out of main/cmp into main/relay
7.1 [P][OW] Destroy main/cmp — the final Aurora snapshot is the point of no return
7.2 [OW] Retire the GitHub Pages deployments — ⚠️only the main-site one.docs, faq and podwiki are now permanent redirect targets (C2) and must stay live and maintained.
7.3 Retire the legacy static site after the 4.4 window closes — ⚠️ it lives inside main/dtc-website/ rather than its own root, so this is surgery on a shared root, not a clean destroy
7.4 Sandbox preconditions, then teardown in the corrected order: sandbox Relay → sandbox/website → CMP (7.1) → sandbox Datamailer. Datamailer retires last, held alive by three independent dependencies: sandbox Relay sends through its configuration set; CMP production is its client until 7.1; and the D23 suppression copy must complete first. The old ordering counted its ≈$13–15/mo saving too early.
7.6 [P][OW] Destroy sandbox/website; retire its OIDC pipeline and policy fixtures
7.7 [P] Cost Explorer harvest against §14 — expected ≈$270–340/mo freed. ⚠️main/common is not in the harvest: main/aisl runs in its VPC and needs its NAT, so that NAT can never be retired.
Cost — two budgets (D26)
§14 · all figures estimates; Phase 0.3's Cost Explorer pull is the authority for actuals
Scope
Bar
Est. $/mo
Verdict
Budget A
website prod + dev + shared plumbing + the three small Lambdas
≤ CMP's $180–225
169.98
PASS
Budget B
Relay prod + dev
judged separately
155.48
40% is SES postage
Combined
the actual invoice
—
325.46
vs ≈$193–240 today
Confirm Budget A's assumptions hold at implementation: single-AZ RDS (D9), dedicated dev database (D29), NAT gateway (D32), Container Insights ON
Record the transitional peak in the budget alarm: ≈**$597–665/mo** while everything runs at once, ≈$305/mo above steady state. Compressing the overlap is worth more than any single sizing decision.
Seven corrections the costed design found in §14, all now folded in:
Relay priced at $15–18/mo → ≈$144 for Relay production alone
"Turn Container Insights off" would put web_running_tasks and worker_running_tasks into permanent ALARM (modules/django-website/observability.tf:27-78, treat_missing_data = "breaching") — withdrawn
The cited "free S3 gateway endpoint in the module" does not exist — modules/django-website has no VPC endpoint at all, so a new VPC ships with the July 2026 NAT-data failure mode un-fixed unless the endpoint is written at root level
A new NAT does not replace an existing cost — main/aisl runs in main/common's VPC and needs its NAT
Aurora Serverless v2 re-examined at a 0-ACU floor and still rejected — the dev readiness probe prevents the pause, so it bills ≈$43.80/mo
§9.4's apex swap was incomplete — aws cloudfront associate-alias is required
§7.4's teardown order understated the transitional bill
Content ingestion
Runs in parallel with its own gates · §13
13.3 Direct-sync programme — in flight, all gates closed
13.4 Ongoing update path: target state plus its approval and rollback story
13.7 Settle the genuinely undecided ingestion items
13.8 Events: the Luma data dump, the freshness gate, and the PII boundary
13.9 Full-fidelity CMP database import — two modes specified, only one built
Data sources pinned correctly: the production export is the newest /data/tmp/rds-export/rds-prod-*.db, produced nightly by cron (six rotations on disk — always take the newest). course-management-platform/db/db.sqlite3 is CMP's dev database and must not be used for schema mapping.
⚠️Schema drift is a real blocker with no escape hatch. The three post-pin tables are not empty in production — courses_emailcampaign 1, courses_systemprojectevaluation 1, courses_systemevaluationcriteriaresponse 11 — so make review-data's fail-closed refusal cannot be waved away as "they're empty anyway". Adopt the upstream migrations or re-pin the adoption; no hand-editing dumps. Re-count at freeze time.
⚠️Project Attempt N is real production copy, not placeholder junk — 31 of 52 courses_project rows, with a public FAQ entry explaining it (content/faq_projection.json:310). The real placeholder marker is the description prefix Production-like generated (courses/services/local_course_seed.py:362), which appears zero times in production. Filter on that, never on the title.
13.10 Course-repository curriculum layout and the unit content pipeline (D18–D21, D24, D25) — detail in _docs/design/specs/unit-content-pipeline.md
Move machine-learning-zoomcamp's 9 and ai-dev-tools-zoomcamp's 4 module directories under cohorts/2026/, repairing in the same commit: nine homework.md collisions, 102 relative image references, 103 ](../) back-links
Rewrite llm-zoomcamp/cohorts/README.md, which documents the opposite convention
Carry the change into DataTalksClub/zoomcamp-template (D21)
Delete the ML homework.md module-directory fallback heuristic (unit_links.py:168-171) with the move, not before
Fix the 4 ai-dev-tools lesson bodies that open # Module N — <Title> while their unit title is <Title> — a content fix in the course repo; the stripper is correct to leave them
Every repository-relative link a lesson writes now resolves — 272 relative non-.md links (45 + 219 + 8), 0 unresolved. Independently re-measured; a "288" figure that circulated is not reproducible under this definition.
Duplicate title heading removed — 103 of 181 units were repeating their title; removals go from 74 to 177 of 181, the 4 remaining being the ai-dev-tools lessons above
_VIDEO_LINE_RE deleted — it matched 0 of 181 units
⚠️ New columns video_url, code_sources (migration 0053) and instructions_source_path (0054) are empty until a reimport, which needs a fresh commit or a cleared run row
The "Edit on GitHub" 404 mechanism is branch-path absence, not commit-SHA resolution — UnitRepository.edit_url() builds {base}/edit/{branch}/{source_path} (courses/services/unit_assets.py:63-70); no commit SHA appears in any public affordance. The fbab381 guard is still right and stays.
Reference layers
Not steps, but the analysis the steps depend on: §14 cost and security (two budgets) · §14.6 the policy-suite blockers · §15 migration-script specifications · §16 risk register · §17 what this runbook does not cover · Appendix A DNS parity worksheet.
Known blockers outside this checklist
Push llm-zoomcamp@c04db93 and machine-learning-zoomcamp@1aa481e — done. Both are now on their repositories' origin/main (944b35e, 61fdaab). Under D18, c04db93 is the correct layout and ships.
Push the newer SITE.md commits, which are still local-only in all three repositories — and settle D28 first, since origin/main's course.yaml still carries description_path, which the parser now rejects as an unknown key
Get consent from the people quoted on the homepage.
No way to generate article, podcast and event assets in the current style #311 — a repeatable way to generate article, podcast and event assets in the current design style. Courses have one (_docs/design/illustration-assets.md); the three editorial content types do not, and 213 podcast covers are being regenerated.
Decide the eight external link literals gating the events refresh
Author or generate the speaker-bio normalization rows for the nine new events
Decide whether the accent-border ban is global (it is enforced in practice but written down nowhere)
Tracking issue for the production hosting, DNS and content migration. The authority is the runbook —
_docs/runbooks/production-hosting-and-dns-migration.md. This issue is the tick-off surface; the runbook holds the detail, the rationale and the verification for every line below. Section references (§) point into it.What this migration does.
datatalks.clubmoves off GitHub Pages andcourses.datatalks.clubmoves off CMP, both onto this Django deployment, with DNS moving from GoDaddy to Route 53 and email moving to Relay in the main AWS account.Markers. [P] needs production AWS credentials (
387546586013) · [G] needs GoDaddy console access · [OW] one-way or expensive to reverse · [W:x] hard wait of duration x · [B:x] blocked on x.Lanes. MAIN is the apex/DNS critical path. COURSES, EMAIL, DEV and CLEANUP run in parallel with MAIN and with each other — only the order within a lane is strict, plus the named cross-lane joins.
Companion design documents (linked from the runbook, not restated in it):
_docs/design/specs/data-migration-architecture.md·_docs/design/specs/unit-content-pipeline.md·_docs/design/specs/script-inventory.md· the costed Terraform design for website prod/dev + Relay prod/dev, currently at.tmp/infra-design-dev-prod-relay.mdand outside version control (D36).Decisions
Resolved (§3.1):
dev.; the sandbox account is retiredcourses.(maintenance, then redirect)cohorts/<year>/<module>/module.yaml.llm-zoomcampalready conforms (7 modules);machine-learning-zoomcamp(9) andai-dev-tools-zoomcamp(4) move.llm-zoomcamp@c04db93(_docs/runbooks/session-handoff-20260902.md:53-55) — under D18 that commit is correct andllm-zoomcamp/cohorts/README.mdis what is wrong. No public URL moves.stock-markets-analytics-zoomcamp, which stayslegacyfor 2026.data-engineering-zoomcampis a candidate to assess, not a commitment.DataTalksClub/zoomcamp-templateowns that convention (STRUCTURE.md,docs/conventions.md,templates/{root,module,cohort}-README.md). Any layout change includes that repository.dev.datatalks.clubcarries fake data, never production-derived. Dev Relay therefore never sees a real address, which is what makes the shared dev database low-stakes.^hw(\d+)$→homework-0Ntransform is removed, explicit overrides onlyai-bootcamp-2025,ai-hero-2025,ai-hero-2026,sma-zoomcamp-2026,ai-buildcamp-2,ai-buildcamp-3. Four need only a mapping entry;ai-buildcamp-2/-3carry edition numbers, not years, which the family+year model cannot express — that pair needs design./docs/,/faq/,/podwiki/redirect is accepted; no action (also recorded at C3/C4/C5 below)Open (§3.2) — each one gates the steps that cite it:
mainin three repos (short copy, deletesdescription_path), one as six PRs fromorigin/main(551–635-char copy fromcontent/docs_projection.json, repointsdescription_path). The website side has already landed and decides part of it —course.yamlno longer acceptsdescription_pathat all, so the repointing lane would be rejected withunknown_key. Recommended default: the first lane's convention with the second lane's copy.db.t4g.micro(≈$15.68/mo) or logical databases on the production instance ($0). Recommended: dedicated.db.t4g.medium(≈$52.56) ordb.t4g.small(≈$26.28). Recommended: medium.main/dtc-websitejoins the policy scan (adding it converts an existing grant into a violation). Recommended: after the apex swap, sequenced with D4.modules/relay-hostor duplicate two Relay roots. Recommended: extract.main/commonbastion in favour of SSM Session Manager (−$6.70/mo, removes a 24/7 SSH-exposed host)Phase 0 — Inventories and prerequisites
MAIN · read-only · §5
_domainkeysweepwwwandjoinforwarding configurationsZ00653771…)Max24HourSendmust clear ~130,000 recipients in a day alongside transactional traffic, andMaxSendRatedecides whether a 130,000 campaign takes ~2.6 h (at 14/s) or ~11 min (at 200/s). If either falls short, the increase is a support request with multi-day lead time and should be raised at once.make compatibility-artifacts-check)Phase 1 — DNS hosting moves to Route 53
MAIN · §6
main/dnszone; byte-copy every record from 0.1 —main/dtc-website/dns.tfrather than a separatemain/dnsroot (304189f,73d3364). Note the zone was created while D6 is still open, and the apex record still points at GitHub Pages rather than the new distribution. Apply status not determinable from the repo.joinreplacement: API Gateway custom domain, with the ACM CNAME added at GoDaddy nowwwwstrategy — build the Phase 2 distribution first (recommended)dns-parity-checkgreen, GoDaddy TTLs lowered [W:48h]Phase 2 — Apex to S3/CloudFront (stage 1 of the D1 swap)
MAIN · needs Phase 1 · §7 · ✅ EXECUTED 2026-09-02, with a design change
Only the main site tree was rehosted into S3.
/docs/,/faq/and/podwiki/were not rebuilt — a CloudFront viewer-request Function now302s them todatatalksclub.github.io. Verified live:x-cache: FunctionGeneratedResponse from cloudfront.source_iddtc-main-sitedtc-docsdtc-faqdtc-podwiki636 rows — 21.7% of the compatibility contract — now redirect instead of returning 200.
dtc-main-site; the other three are no longer rebuilt at allmain/dtc-website/legacy_static_site.tf(038d4c3,3d66411,580ef4b) rather than a separatemain/legacy-siteroot.Follow-ups this created
classification: "preserve"/expected_status: 200. The baseline schema hard-codesconst: "preserve", so a redirect cannot be expressed in it — only through the digest-bound approved-expectations sidecar. Until that is recorded, the 2.4 parity crawl reports 636 regressions and cannot function as a gate — not for this phase, and not for the 9.4 stage-2 swap that depends on it. Highest priority in the phase.docs,faqandpodwikimust stay live and maintained indefinitely — the apex holds no copy of their content. This invalidates 2.6 ("frozen for ≥30 days") and 7.2 ("retire the four Pages deployments") for three of the four.rel=canonicalon the FAQ tree (152 rows; docs and podwiki do carry one), the 302-vs-301 question, and the apexrobots.txtstill advertisingSitemap: https://datatalks.club/podwiki/sitemap.xmlwhich now redirects off-domain. Recorded so they are not re-raised. Note this does not touch C1 or C2, which are contract and availability problems rather than search problems./podwiki/*URLs would 404 at cutover; they redirect now. Close or rewrite it rather than leaving it describing a condition that no longer exists.Phase 3 — Media to S3
Independent of MAIN · #301 · §8
main/dtc-website(bucket and upload role only)images/podcast/s24e06-how-to-build-ai-that-actually-ships-in-production.jpg, unrecorded on disk and correctly absent from the bucket; still needs a decision on whether to record it or delete itmedia-syncinitial upload — done; all 1,253 objects are present ins3://dtc-website-media/media-verify: 1,253/1,253 digest matches plus a two-way set reconciliations3:ListBucket, so it cannot be done anonymously; there is currently no proof the bucket holds no extra objectsmedia-syncrun should report 0 uploads, but that is inferred from readingcontent/media_tooling.py:285, not observed57fc3ae); the/images/*CloudFront behavior itself still waits on the Phase 4 distributionPhase 4 — Production stack,
prod.anddev., stage-2 swapMAIN + DEV · §9
4.0 [P] Policy-suite amendment first — credential-free and the longest pole. Three fail-closed blockers, all verified in
~/git/aws-infra(§14.6):SOURCE_DIRECTORY_SCOPESis a literal four-element tuple (tests/policy/safe_repository_inputs.py:16-20) and contains none of the new roots; adding one drags inEXPECTED_TERRAFORM_CONFIG_FILES, the source manifest, and the pinned test countsDataTalksClubowner cannot passOIDC004/OIDC005— both force exact sandbox claim values todayaws_iam_instance_profilehas zero permitted owners, so Relay cannot enter scope at allmain/dtc-websiteconverts its existingcloudfront:CreateInvalidationgrant (legacy_static_site.tf:459-469) into a violation — defer until after the apex swapmain/maintenance-pagehas noenable_deploymentgate, no backend block, noallowed_account_ids, no tags — anapplyat defaults stands up a live public HTTP API4.1 [P] [B:D9] Production website root — module instantiation, with policy-suite churn budgeted (§9.1). Root naming still open between
main/dtc-websiteandmain/website; the costed design recommendsmain/website.4.2 [P] Minimal
dev.datatalks.clubon shared production infrastructure; tradeoffs accepted under D2 (§9.2) and made low-stakes by D22 (fake data)4.3 Staging discipline while
prod.and the apex both serve: four duplicate-content controls,prod.noindexed (§9.3)4.4 [P] [OW-SEO] Stage-2 apex swap (§9.4), after a six-item gate:
prod.against the live apex/docs/,/faq/,/podwiki/→ github.io redirect survives the swap (C7) — either the new distribution carries the same CloudFront Function, or Django answers those 636 paths itselflegacy_static_site.tf:269) — a second distribution getsCNAMEAlreadyExists. Order:aws cloudfront associate-aliasonto the new distribution → flip the Route 53 aliases → then remove the names from the legacy distribution'saliases. Doing the last step first turns a rehearsed swap into an outage. Alias rollback is 1–10 min; keep the S3 legacy site warm ≥30 days.Phase 5 — Anonymous edge caching
MAIN · must be proven before 4.4 · §10
Set-Cookie/CSRF testsPhase 6 —
courses.datatalks.club, two LambdasCOURSES lane · fully parallel, the zone is already on Route 53 · §11
Retry-After; track the root, add a backend — themain/maintenance-pageroot now exists (fcdcdbf) but still returns 200 (lambda/index.js:433-439), and 🚨 unlikemain/courses-redirectit has noenable_deploymentgate at all — every resource is unconditional, so anapplytoday creates live resources. "Inactive" here means only that nobody has applied it.dev.courses.datatalks.clubmain/courses-redirect: generated path map, 301 on GET/HEAD, gated 308 — root written (fcdcdbf) and genuinely inert: every resource carriescount = var.enable_deployment ? 1 : 0, defaulting to falsePhase E — Email via Relay
EMAIL lane · parallel, does not wait for DNS · §11E
Volume, and what it changes. 130,000 newsletter recipients weekly plus transactional ≈ 563,000 messages/month. Consequences already folded into the runbook: SES postage alone is ≈$56/mo (≈$56–91 depending on the real cadence — D31); delivery history grows 0.65–1.04 GB per weekly campaign, 34–54 GB/year unpruned; and §14's old "$15–18/mo for all of
main/relay" was wrong by roughly 8× — Relay production is ≈$144/mo.aws-infra/docs/aws-support/2026-08-09-ses-newsletter-quota-increase.md:19) and the quota is raised. That document records the 2026-08-09 state (50,000/24 h, 14/s) and two open increase requests to 200,000/24 h and 200/s; its own follow-up to record the approved values was never done. Do it here.main/relayroot:datatalks.clubidentity and a Relay-owned configuration set--memorylimits set for the 1 GiB hostdb.t4g.medium, single-AZ, gp3 50 → 500 GB (D30) — not PostgreSQL on the host as sandbox doesprune_db_task_resultsexists and has never been invoked; RDS storage autoscaling is one-way, so this is a go-live prerequisite, not an open milestonedry_runat volume, then stages 0–4. What constrains the ramp is deliverability evidence — bounce and complaint rates — not sending capacity. Hard preconditions: the canary andoutbox_*alarms re-pointed at Relay with a live subscriber proven; suppression state copied (E.4a) before stage 1; pruning proven before stage 4. Abort thresholds: 3% bounce, 0.08% complaint.Phase 7 — Decommission and cost harvest
CLEANUP · after every rollback window has closed · §12
state mvout ofmain/cmp— execute early, together with E.3main/cmpintomain/relaymain/cmp— the final Aurora snapshot is the point of no returndocs,faqandpodwikiare now permanent redirect targets (C2) and must stay live and maintained.main/dtc-website/rather than its own root, so this is surgery on a shared root, not a clean destroysandbox/website→ CMP (7.1) → sandbox Datamailer. Datamailer retires last, held alive by three independent dependencies: sandbox Relay sends through its configuration set; CMP production is its client until 7.1; and the D23 suppression copy must complete first. The old ordering counted its ≈$13–15/mo saving too early.sandbox/website; retire its OIDC pipeline and policy fixturesmain/commonis not in the harvest:main/aislruns in its VPC and needs its NAT, so that NAT can never be retired.Cost — two budgets (D26)
§14 · all figures estimates; Phase 0.3's Cost Explorer pull is the authority for actuals
Seven corrections the costed design found in §14, all now folded in:
web_running_tasksandworker_running_tasksinto permanent ALARM (modules/django-website/observability.tf:27-78,treat_missing_data = "breaching") — withdrawnmodules/django-websitehas no VPC endpoint at all, so a new VPC ships with the July 2026 NAT-data failure mode un-fixed unless the endpoint is written at root levelmain/aislruns inmain/common's VPC and needs its NATaws cloudfront associate-aliasis requiredContent ingestion
Runs in parallel with its own gates · §13
/data/tmp/rds-export/rds-prod-*.db, produced nightly by cron (six rotations on disk — always take the newest).course-management-platform/db/db.sqlite3is CMP's dev database and must not be used for schema mapping.courses_course21,courses_project52,courses_question616,courses_homework128; 20,009 accounts / 20,907 enrollments / 36,547 submissionscourses_emailcampaign1,courses_systemprojectevaluation1,courses_systemevaluationcriteriaresponse11 — somake review-data's fail-closed refusal cannot be waved away as "they're empty anyway". Adopt the upstream migrations or re-pin the adoption; no hand-editing dumps. Re-count at freeze time.Project Attempt Nis real production copy, not placeholder junk — 31 of 52courses_projectrows, with a public FAQ entry explaining it (content/faq_projection.json:310). The real placeholder marker is the description prefixProduction-like generated(courses/services/local_course_seed.py:362), which appears zero times in production. Filter on that, never on the title._docs/design/specs/unit-content-pipeline.mdmachine-learning-zoomcamp's 9 andai-dev-tools-zoomcamp's 4 module directories undercohorts/2026/, repairing in the same commit: ninehomework.mdcollisions, 102 relative image references, 103](../)back-linksllm-zoomcamp/cohorts/README.md, which documents the opposite conventionDataTalksClub/zoomcamp-template(D21)homework.mdmodule-directory fallback heuristic (unit_links.py:168-171) with the move, not before# Module N — <Title>while their unit title is<Title>— a content fix in the course repo; the stripper is correct to leave themContent-pipeline fixes already landed (
aed2497,c928f37,fbab381,2c97886,1b1a49f,1b8352a):.mdlinks (45 + 219 + 8), 0 unresolved. Independently re-measured; a "288" figure that circulated is not reproducible under this definition._VIDEO_LINE_REdeleted — it matched 0 of 181 unitsvideo_url,code_sources(migration 0053) andinstructions_source_path(0054) are empty until a reimport, which needs a fresh commit or a cleared run rowUnitRepository.edit_url()builds{base}/edit/{branch}/{source_path}(courses/services/unit_assets.py:63-70); no commit SHA appears in any public affordance. Thefbab381guard is still right and stays.Reference layers
Not steps, but the analysis the steps depend on: §14 cost and security (two budgets) · §14.6 the policy-suite blockers · §15 migration-script specifications · §16 risk register · §17 what this runbook does not cover · Appendix A DNS parity worksheet.
Known blockers outside this checklist
Push— done. Both are now on their repositories'llm-zoomcamp@c04db93andmachine-learning-zoomcamp@1aa481eorigin/main(944b35e,61fdaab). Under D18,c04db93is the correct layout and ships.SITE.mdcommits, which are still local-only in all three repositories — and settle D28 first, sinceorigin/main'scourse.yamlstill carriesdescription_path, which the parser now rejects as an unknown key_docs/design/illustration-assets.md); the three editorial content types do not, and 213 podcast covers are being regenerated.Redirect legacy /podwiki/* URLs to the canonical /wiki/* routes #306 — 310 legacy— superseded, they now 302 to github.io (see C6)/podwiki/*URLs would 404 at cutover