Migrate to Render via render.yaml blueprint#135
Merged
Conversation
Point the test suite at its own Postgres database (laravel_test) instead of sharing the dev database. Tests using RefreshDatabase wipe the schema on each run, which would clobber local dev data when both dev and test point at the same DB. Update dev-setup.md so a fresh clone creates both databases and grants privileges to the root user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set TrustProxies::\$proxies = '*' so the app trusts X-Forwarded-Proto and X-Forwarded-Host from Render's ingress. With the previous null value the framework discarded proxy headers, so request()->isSecure() returned false behind TLS termination and URL generation emitted http:// links. Trusting any proxy is safe on Render because the container has no internet path other than Render's ingress — there is no route an attacker could take to forge these headers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pin the test DB credentials to the macOS Homebrew superuser (david / no password) so 'php artisan test' works out of the box on this machine without requiring a local .env tweak. To be discussed in code review — this is a machine-specific default and may want to become a more portable setup (e.g. a Postgres role created by the dev-setup script) if other devs touch this repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render's web service health check polls /healthz every few seconds. With the route inside the default web middleware group, each poll went through StartSession, which wrote a new row to the sessions table on every poll (session driver is database). On a site with 5-second polls that would add ~17k rows/day from healthchecks alone. Strip the cookie/session middleware from the route so the healthcheck stays a pure read. CSRF is also skipped because it's a safe GET endpoint with no state. Add a feature test asserting no Set-Cookie header and no session row after hitting /healthz. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Change the Caddyfile site address from a hardcoded davidharting.com to
:{\$PORT:80} so Caddy listens on whatever port Render injects via the
PORT env var (or 80 when run locally without PORT set).
Render terminates TLS at its ingress and proxies plain HTTP to the
container — binding to a hostname-based site address would cause Caddy
to attempt ACME cert provisioning and refuse connections that don't
match the host header.
The worker, encode, request_body, and php_server directives inside the
site block work the same regardless of site address, so no other
changes are needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rework the Dockerfile to match Render's expectations: - Drop the entrypoint wrapper (was only there to re-export Docker Compose secrets from /run/secrets/*; Render injects env vars directly into the process) - Drop --https --http-redirect from CMD; Render terminates TLS and proxies plain HTTP to the container. The CMD is also overridden by render.yaml dockerCommand so this is mostly cosmetic, but matches what the container will actually run - Reorder build layers so composer/npm dependency installs cache independently of source-file changes. Previously COPY . /app came before dependency installs, so any PHP or Blade edit re-ran composer install + npm ci. Now the lockfiles copy first, deps install into cached layers, and only the final dump-autoload + post-autoload-dump + npm run build layers re-run on source changes - Add --no-dev to composer install (production image should not ship pint / pest / mockery / faker / collision / sail) - Add VITE_APP_NAME env var before npm run build so the name ends up baked into the JS bundle (build-time var, not runtime) - Delete docker-entrypoint.sh (no longer referenced) - Add render.yaml to .dockerignore so the blueprint doesn't inflate the build context Not build-verified locally — no Docker daemon running on this machine. First validation will happen on Render when the blueprint is applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Compose stack was the production deployment shape on the Digital Ocean droplet (web / worker / cron / database / nightwatch-agent orchestrated together). It was also used to simulate production locally, but per the migration plan local Docker Compose is no longer a representative production simulation — tests against Render need to happen on Render. The production secrets/ directory referenced by Compose's file-based secrets is gitignored so nothing to remove from the repo on that front. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render builds the image from the repo Dockerfile on every deploy (runtime: docker in render.yaml), so the GitHub Actions workflow that pushed to ghcr.io/davidharting/davidharting.com is no longer needed. The pr.yml workflow stays — it runs the test suite on PRs. A follow-up commit extends it to also run on push: main so Render's "After CI Checks Pass" auto-deploy mode has a check to wait on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related changes to support Render's "After CI Checks Pass" auto-deploy mode: - Extend the workflow trigger from pull_request-only to also include push: main. Render refuses to deploy a commit that has zero CI checks; the prior config meant merge commits on main ran no checks - Rename the workflow from "Pull Request CI" to "CI" since it now covers both entry points Two follow-ons that make the test DB split from "tests: isolate test database to laravel_test" actually work in CI: - Change the CI postgres service's POSTGRES_DB from laravel to laravel_test to match phpunit.xml - Add force="false" to DB_USERNAME/DB_PASSWORD in phpunit.xml so the hardcoded macOS defaults (david / empty) only apply when the environment does not already supply credentials, and pass explicit DB_USERNAME=root / DB_PASSWORD=password env in the CI test step so the CI postgres service's auth is used Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the Digital Ocean / Docker Compose description with the Render topology so future Claude sessions have an accurate mental model: web + worker + scheduler as separate Render services building from one Dockerfile, TLS terminated at the ingress, DATABASE_URL wired from managed Postgres, preDeploy-based migrations, etc. Adds a pointer to docs/projects/render-migration.md for the migration history (runbook to be added in a follow-up commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Write a living runbook at docs/projects/render-migration.md covering the why, target architecture, decisions, env var inventory, first- deploy steps, data migration procedure, smoke tests, DNS cutover plan, rollback options, and v2 follow-ups. The document mirrors the frontmatter convention of the other project docs (name + status) and tracks remaining phases as a checklist so future sessions can pick up where implementation left off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Blueprint-as-code for the Render migration. Defines:
- Managed Postgres 17 (basic-1gb, diskSizeGB 1, storage autoscaling,
region ohio) — paid tier for PITR + logical backups
- One envVarGroup for shared runtime config. All secrets declared with
sync: false so Render prompts once at blueprint creation and the
values are then managed via the dashboard (blueprint updates ignore
sync: false entries)
- Three services built from the repo Dockerfile:
• web — FrankenPHP Octane on \$PORT, preDeployCommand runs
migrations + Telegram webhook registration
• worker — queue:work with --tries=3 --backoff=30 to survive
the short window where preDeploy hasn't run on its
own pipeline yet
• scheduler — schedule:work as a long-running worker (NOT a
Render cron — CronJobV2 cold-start is risky for the
hourly pg_dump backup tick)
- DATABASE_URL per service via fromDatabase.connectionString so
Laravel's built-in DB URL parsing can wire host/port/user/password/
database in one shot (config/database.php line 68)
APP_URL starts as a placeholder https://TBD.onrender.com and must be
updated to the real generated URL after first provision (triggers a
redeploy whose preDeploy re-registers the Telegram webhook). Update
again to https://davidharting.com after DNS cutover.
After first provision, set each service's Auto-Deploy in the Render
dashboard to "After CI Checks Pass" so Render waits on the GitHub
Actions CI workflow before promoting a commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The FrankenPHP base image ships with PHP's compiled default memory_limit of 128M, which is on the low side for a Laravel Octane worker that stays resident across requests. 256M gives comfortable headroom for: - Filament admin requests that render large table exports - Media-import Actions pulling in Goodreads / Sofa rows - Spatie backup job memory spikes (though most of the work is shelled out to pg_dump) - Any exception path that re-renders a debug view (production has APP_DEBUG=false so this is belt-and-suspenders, but cheap insurance) Also switch from printf via 'echo' (which relies on POSIX behavior for \n — Debian's /bin/sh handles it, but printf is the portable choice) and rename the conf to a generic php.ini since it now holds more than just upload limits. The starter plan has 512MB RAM so 256M per PHP process fits fine. If the worker service ever serves enough concurrent requests to need more, we can bump further or move to a larger Render plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirmed via FrankenPHP's performance docs that the dunglas/frankenphp image does NOT override php.ini — the 128M we'd otherwise inherit is PHP's compile-time default, not a deliberate FrankenPHP choice. Add a comment capturing: - Why we override it (Octane worker threads holding allocations across requests, Filament/import actions needing headroom) - FrankenPHP's sizing rule num_threads × memory_limit < available_memory - How that math works out on each Render plan so future-us knows to revisit if we upgrade past starter - Why queue / scheduler workers don't need the same concern (single process, no thread multiplier) No code change beyond the comment — 256M still stands for starter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the exact docker run incantation I used to validate the image locally, plus a warning about port conflicts. I burned time during this migration chasing what looked like a Caddy HTTPS-redirect bug, when the real cause was a host-side process (tilt in my case, also sometimes ssh tunnels or Docker Desktop port forwards) already listening on port 8080 and intercepting traffic. curl from inside the container returned 200; curl from my laptop returned 301. The fix was "use a different port." The runbook now calls out checking lsof and picking a known-free port first so future-us doesn't repeat the rabbit hole. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nest databases, envVarGroups, and services under a projects[].environments[] block so all resources live together in one Render "project" (named davidhartingdotcom) with a single "prod" environment. The dashboard will group them visually and env var groups are scoped to the environment rather than workspace-wide. No functional change to any service: same region (ohio), same plan (basic-1gb postgres, starter for web/worker/scheduler), same DATABASE_URL wiring via fromDatabase.connectionString, same preDeployCommand and CMDs. Only the YAML hierarchy changed. Per the blueprint spec, resources defined under an environment cannot also exist at the root level — the top-level databases / envVarGroups / services keys are now empty/absent and everything is owned by the project/environment. Follow-up note: after initial provisioning, if we ever want a staging environment we can add a second `environments[]` entry under the same project. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
basic-256mb is the smallest paid Render Postgres tier. It keeps every backup/recovery feature that matters: - Continuous PITR backups (3-day recovery window on Hobby workspace, 7-day on Pro+) - 7-day logical backup retention - Manual logical backup trigger from the dashboard - Storage autoscaling (+50% at 90% full) Those features gate on "paid vs free", not on instance size. Only real tradeoff versus basic-1gb is 256MB RAM / lower connection limit, which is plenty for a personal site with database-backed cache/session/queue drivers. We already have three independent DB-recovery paths (Render PITR, Render logical backups, Spatie hourly pg_dump to R2), so the reliability posture from the plan file still holds. Runbook updated to reflect the new default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Phase 2 (render.yaml) is done, including the project/environment wrapping and the basic-256mb plan - Added an explicit line for the local Dockerfile smoke test that already passed Phase 3 onward requires the Render dashboard, so they stay unchecked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render exec's preDeployCommand directly (no shell), so the && operator was being passed as a literal argument to php artisan migrate rather than being interpreted as shell chaining. Extract the three commands into scripts/predeploy.sh (set -e, one command per line) and call it via `bash /app/scripts/predeploy.sh`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration is complete: data migrated, DNS cut over, DO droplet powered off pending deletion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
davidharting
commented
May 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrate davidharting.com from Docker Compose on a Digital Ocean droplet to Render.com, driven by
render.yaml(Blueprint-as-code). Plan and decisions captured indocs/projects/render-migration.md.render.yamldefines Postgres 17 (basic-1gb, Ohio) + three Docker services: web (FrankenPHP Octane), worker (queue:work), and a worker-type scheduler (schedule:work— deliberately not a Render cron, see below)TrustProxies::$proxies = '*'(was null →request()->isSecure()returned false behind the proxy)/healthzopts out of session/cookie middleware so health checks don't write session rowsCaddyfilebinds:{$PORT:80}instead of a hardcoded hostname--no-devcomposer install,VITE_APP_NAMEbaked in at build time,memory_limit=256Mdocker-compose.yml,docker-entrypoint.sh, and the GHCR image-build workflowpush: mainso Render's "After CI Checks Pass" auto-deploy mode has a check to wait on; test DB split tolaravel_testNotable decisions
pg_dump+ R2 upload could blow the 1-minute tick window.memory_limit = 256Msized for starter plan per FrankenPHP'snum_threads × memory_limit < available_memoryrule. Dockerfile has a comment with the math for larger plans.*.onrender.comURL. Cloudflare custom domain follow-up is in the runbook.Test plan
TrustProxiesTestpasses — X-Forwarded-Proto/Host trusted end-to-end through the middlewareHealthzTestpasses — 200 OK, noSet-Cookieheader, no session row writtenAPP_URLto the generated onrender.com URL, run the smoke-test checklist indocs/projects/render-migration.md🤖 Generated with Claude Code