Skip to content

Migrate to Render via render.yaml blueprint#135

Merged
davidharting merged 33 commits into
mainfrom
render-migration
May 8, 2026
Merged

Migrate to Render via render.yaml blueprint#135
davidharting merged 33 commits into
mainfrom
render-migration

Conversation

@davidharting
Copy link
Copy Markdown
Owner

@davidharting davidharting commented Apr 24, 2026

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 in docs/projects/render-migration.md.

  • New render.yaml defines 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)
  • Supporting app-level fixes so the app works behind Render's TLS-terminating ingress:
    • TrustProxies::$proxies = '*' (was null → request()->isSecure() returned false behind the proxy)
    • /healthz opts out of session/cookie middleware so health checks don't write session rows
    • Caddyfile binds :{$PORT:80} instead of a hardcoded hostname
    • Dockerfile refactored: no more entrypoint wrapper, layers reordered so dependency installs cache across source edits, --no-dev composer install, VITE_APP_NAME baked in at build time, memory_limit=256M
  • Cleanup: deleted docker-compose.yml, docker-entrypoint.sh, and the GHCR image-build workflow
  • CI extended to run on push: main so Render's "After CI Checks Pass" auto-deploy mode has a check to wait on; test DB split to laravel_test

Notable decisions

  • Scheduler is a long-running worker, not a Render cron. CronJobV2 cold-start time stacked with a ~700MB image + hourly pg_dump + R2 upload could blow the 1-minute tick window.
  • memory_limit = 256M sized for starter plan per FrankenPHP's num_threads × memory_limit < available_memory rule. Dockerfile has a comment with the math for larger plans.
  • DNS cutover is deferred — v1 runs on the generated *.onrender.com URL. Cloudflare custom domain follow-up is in the runbook.

Test plan

  • Unit tests: TrustProxiesTest passes — X-Forwarded-Proto/Host trusted end-to-end through the middleware
  • Feature tests: HealthzTest passes — 200 OK, no Set-Cookie header, no session row written
  • Full suite: 295 passed, 0 failures on PHP 8.4 local (same as production)
  • Dockerfile build — not verified locally (no Docker daemon on this machine); first validation happens on Render
  • After creating the Blueprint in Render: paste secrets, update APP_URL to the generated onrender.com URL, run the smoke-test checklist in docs/projects/render-migration.md
  • Set each service's Auto-Deploy to "After CI Checks Pass" in the Render dashboard after first provision

🤖 Generated with Claude Code

davidharting and others added 18 commits April 24, 2026 10:57
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>
@davidharting davidharting deployed to render-migration - davidhartingdotcom-db April 24, 2026 15:48 — with Render Active
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler April 24, 2026 15:49 — with Render Inactive
@davidharting davidharting had a problem deploying to render-migration - davidhartingdotcom-worker April 24, 2026 15:49 — with Render Failure
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>
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler April 24, 2026 16:06 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker April 24, 2026 16:06 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler April 24, 2026 19:21 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker April 24, 2026 19:21 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler April 27, 2026 14:48 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker May 8, 2026 16:15 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-web May 8, 2026 16:15 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler May 8, 2026 16:15 — with Render Inactive
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 davidharting temporarily deployed to render-migration - davidhartingdotcom-web May 8, 2026 16:21 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler May 8, 2026 16:21 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker May 8, 2026 16:21 — with Render Inactive
Comment thread routes/web.php
Comment thread tests/Feature/Http/HealthzTest.php
Comment thread tests/Unit/Http/Middleware/TrustProxiesTest.php
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread render.yaml Outdated
Comment thread render.yaml Outdated
Comment thread render.yaml
Comment thread render.yaml
Comment thread app/Http/Middleware/TrustProxies.php
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler May 8, 2026 16:36 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-web May 8, 2026 16:36 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker May 8, 2026 16:36 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler May 8, 2026 16:43 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-web May 8, 2026 16:43 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker May 8, 2026 16:43 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-scheduler May 8, 2026 18:24 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker May 8, 2026 18:25 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-web May 8, 2026 18:25 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-worker May 8, 2026 18:27 — with Render Inactive
@davidharting davidharting temporarily deployed to render-migration - davidhartingdotcom-web May 8, 2026 18:27 — with Render Inactive
@davidharting davidharting deployed to render-migration - davidhartingdotcom-worker May 8, 2026 18:31 — with Render Active
@davidharting davidharting merged commit 829722e into main May 8, 2026
1 check passed
@davidharting davidharting deleted the render-migration branch May 8, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant