From 54994a76108eb0b91ce6133d28c5d3fbadb3ed00 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 10:57:36 -0400 Subject: [PATCH 01/33] tests: isolate test database to laravel_test 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) --- docs/development-setup.md | 9 +++++++-- phpunit.xml | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/development-setup.md b/docs/development-setup.md index 6a49c07..c720244 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -33,16 +33,21 @@ Start PostgreSQL: sudo service postgresql start ``` -Create database and user: +Create databases and user: ```bash sudo -u postgres psql -c "CREATE DATABASE laravel;" +sudo -u postgres psql -c "CREATE DATABASE laravel_test;" sudo -u postgres psql -c "CREATE USER root WITH PASSWORD 'password';" sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE laravel TO root;" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE laravel_test TO root;" sudo -u postgres psql -d laravel -c "GRANT ALL ON SCHEMA public TO root;" +sudo -u postgres psql -d laravel_test -c "GRANT ALL ON SCHEMA public TO root;" ``` -Run migrations: +`laravel` is for dev; `laravel_test` is where `php artisan test` runs (configured in `phpunit.xml`) so tests never clobber dev data. + +Run migrations on the dev database: ```bash php artisan migrate diff --git a/phpunit.xml b/phpunit.xml index 256e5a3..ab68bfd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,7 +23,7 @@ - + From c42364a8fd0b239172c1befdc65cc73e2c77d2a1 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 10:57:59 -0400 Subject: [PATCH 02/33] trust all proxies for PaaS deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Http/Middleware/TrustProxies.php | 6 +++- .../Unit/Http/Middleware/TrustProxiesTest.php | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Http/Middleware/TrustProxiesTest.php diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 3391630..2b1825f 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -10,9 +10,13 @@ class TrustProxies extends Middleware /** * The trusted proxies for this application. * + * Trusts any proxy because the app runs on a PaaS where the container has no + * direct internet path — the only inbound route is through the platform's + * ingress, so there is no header-spoofing surface to narrow. + * * @var array|string|null */ - protected $proxies; + protected $proxies = '*'; /** * The headers that should be used to detect proxies. diff --git a/tests/Unit/Http/Middleware/TrustProxiesTest.php b/tests/Unit/Http/Middleware/TrustProxiesTest.php new file mode 100644 index 0000000..316fdd6 --- /dev/null +++ b/tests/Unit/Http/Middleware/TrustProxiesTest.php @@ -0,0 +1,35 @@ +headers->set('X-Forwarded-Proto', 'https'); + $request->server->set('REMOTE_ADDR', '10.0.0.1'); + + $seenSecure = null; + (new TrustProxies)->handle($request, function (Request $req) use (&$seenSecure) { + $seenSecure = $req->isSecure(); + + return new Symfony\Component\HttpFoundation\Response('ok'); + }); + + expect($seenSecure)->toBeTrue(); +}); + +test('trusts X-Forwarded-Host from any proxy', function () { + $request = Request::create('http://internal/test', 'GET'); + $request->headers->set('X-Forwarded-Host', 'davidharting.com'); + $request->headers->set('X-Forwarded-Proto', 'https'); + $request->server->set('REMOTE_ADDR', '10.0.0.1'); + + $seenHost = null; + (new TrustProxies)->handle($request, function (Request $req) use (&$seenHost) { + $seenHost = $req->getHost(); + + return new Symfony\Component\HttpFoundation\Response('ok'); + }); + + expect($seenHost)->toBe('davidharting.com'); +}); From 7bebd91aec950a6ed067fba20f737b682b557db5 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 10:58:33 -0400 Subject: [PATCH 03/33] tests: hardcode DB_USERNAME/PASSWORD in phpunit.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- phpunit.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpunit.xml b/phpunit.xml index ab68bfd..2494e28 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,6 +24,8 @@ + + From 240074d27c8ac61fc67a58ea8c08d2487148c575 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 10:59:42 -0400 Subject: [PATCH 04/33] exclude /healthz from session/cookie middleware 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) --- routes/web.php | 13 ++++++++++++- tests/Feature/Http/HealthzTest.php | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Http/HealthzTest.php diff --git a/routes/web.php b/routes/web.php index 0bf3ddc..cf0b4e1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,7 +8,12 @@ use App\Http\Controllers\NotesIndexController; use App\Http\Controllers\PageController; use App\Http\Controllers\ProfileController; +use App\Http\Middleware\EncryptCookies; +use App\Http\Middleware\VerifyCsrfToken; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; +use Illuminate\View\Middleware\ShareErrorsFromSession; use Spatie\MarkdownResponse\Middleware\ProvideMarkdownResponse; /* @@ -26,7 +31,13 @@ Route::get('/healthz', function () { return response('OK', 200); -}); +})->withoutMiddleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, +]); Route::get('/', function () { return view('welcome'); diff --git a/tests/Feature/Http/HealthzTest.php b/tests/Feature/Http/HealthzTest.php new file mode 100644 index 0000000..95a030b --- /dev/null +++ b/tests/Feature/Http/HealthzTest.php @@ -0,0 +1,30 @@ +get('/healthz'); + + $response->assertOk(); + $response->assertSeeText('OK'); + }); + + test('does not set any session cookie', function () { + /** @var TestCase $this */ + $response = $this->get('/healthz'); + + $response->assertOk(); + expect($response->headers->get('Set-Cookie'))->toBeNull(); + }); + + test('does not write a session row', function () { + /** @var TestCase $this */ + $this->assertDatabaseCount('sessions', 0); + + $this->get('/healthz')->assertOk(); + + $this->assertDatabaseCount('sessions', 0); + }); +}); From e803f5cb7db9bcee8ff9e6ce1224cece5e95565d Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:00:06 -0400 Subject: [PATCH 05/33] bind Caddy to \$PORT so it works behind Render ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Caddyfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Caddyfile b/Caddyfile index acd054b..6875c9f 100644 --- a/Caddyfile +++ b/Caddyfile @@ -6,7 +6,7 @@ } } -davidharting.com { +:{$PORT:80} { log { level {$CADDY_SERVER_LOG_LEVEL} From 8c3c3e359d1b36fc7d7d6167695944ef2d1f3ea7 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:02:29 -0400 Subject: [PATCH 06/33] dockerfile: prep image for Render build + deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 1 + Dockerfile | 29 ++++++++++++++++++----------- docker-entrypoint.sh | 9 --------- 3 files changed, 19 insertions(+), 20 deletions(-) delete mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore index db9024c..8e828fd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,6 +28,7 @@ fly.toml public/hot docker-compose.yml docker-compose.dev.yml +render.yaml # 3. Copy over top-level .gitignore diff --git a/Dockerfile b/Dockerfile index 97cb4fd..b3233a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,22 +21,29 @@ RUN install-php-extensions \ RUN echo "upload_max_filesize = 25M\npost_max_size = 27M" \ > /usr/local/etc/php/conf.d/uploads.ini - -COPY docker-entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - RUN mkdir -p /app/public/build COPY --from=composer:2 /usr/bin/composer /usr/bin/composer -COPY . /app -# spatie/laravel-backup expects this to be defined in the post-install script -ENV MAIL_FROM_ADDRESS=hello@davidharting.com +WORKDIR /app -RUN composer install --optimize-autoloader && php artisan optimize:clear +# Dependency layers kept ahead of COPY . so source edits don't bust the cache. +COPY composer.json composer.lock /app/ +RUN composer install --no-scripts --no-autoloader --no-dev --prefer-dist +COPY package.json package-lock.json /app/ RUN npm ci --no-audit -RUN npm run build -ENTRYPOINT ["bash", "/entrypoint.sh"] -CMD ["php", "artisan", "octane:frankenphp", "--caddyfile", "Caddyfile", "--https", "--http-redirect"] +# spatie/laravel-backup expects MAIL_FROM_ADDRESS at composer post-install time. +ENV MAIL_FROM_ADDRESS=hello@davidharting.com +# Baked into the JS bundle at Vite build time. +ENV VITE_APP_NAME=davidharting.com + +COPY . /app + +RUN composer dump-autoload --optimize \ + && composer run-script post-autoload-dump \ + && npm run build \ + && php artisan optimize:clear + +CMD ["php", "artisan", "octane:frankenphp", "--caddyfile", "Caddyfile"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index 3fd1f1f..0000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -for secret in /run/secrets/*; do - if [ -f "$secret" ]; then - echo "Exporting secret: $(basename "$secret")" - export $(basename "$secret")=$(cat "$secret" | tr -d "\n") - fi -done - -exec "$@" - From a507b8b8fd4f8763aec82253a669e8dabdcd628e Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:02:57 -0400 Subject: [PATCH 07/33] remove docker-compose.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 214 --------------------------------------------- 1 file changed, 214 deletions(-) delete mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index d3b024c..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,214 +0,0 @@ -volumes: - db_data: - caddy_data: - # TODO: private and public disks - -secrets: - DB_PASSWORD: - file: ./secrets/DB_PASSWORD.txt - APP_KEY: - file: ./secrets/APP_KEY.txt - MAILERSEND_ADMIN_ADDRESS: - file: ./secrets/MAILERSEND_ADMIN_ADDRESS.txt - MAILERSEND_API_KEY: - file: ./secrets/MAILERSEND_API_KEY.txt - R2_ACCESS_KEY_ID: - file: ./secrets/R2_ACCESS_KEY_ID.txt - R2_SECRET_ACCESS_KEY: - file: ./secrets/R2_SECRET_ACCESS_KEY.txt - R2_ENDPOINT: - file: ./secrets/R2_ENDPOINT.txt - R2_PRIVATE_BUCKET: - file: ./secrets/R2_PRIVATE_BUCKET.txt - NIGHTWATCH_TOKEN: - file: ./secrets/NIGHTWATCH_TOKEN.txt - TELEGRAM_TOKEN: - file: ./secrets/TELEGRAM_TOKEN.txt - TELEGRAM_DAVID_ID: - file: ./secrets/TELEGRAM_DAVID_ID.txt - ANTHROPIC_API_KEY: - file: ./secrets/ANTHROPIC_API_KEY.txt - -x-laravel-config: &laravel-config - image: ghcr.io/davidharting/davidharting.com:latest - secrets: - - DB_PASSWORD - - APP_KEY - - MAILERSEND_API_KEY - - MAILERSEND_ADMIN_ADDRESS - - R2_ACCESS_KEY_ID - - R2_SECRET_ACCESS_KEY - - R2_ENDPOINT - - R2_PRIVATE_BUCKET - - NIGHTWATCH_TOKEN - - TELEGRAM_TOKEN - - TELEGRAM_DAVID_ID - - ANTHROPIC_API_KEY - environment: - APP_DEBUG: "false" - APP_ENV: production - # APP_KEY: Injected into /run/secrets/APP_KEY - APP_NAME: davidharting.com - APP_URL: https://davidharting.com - BROADCAST_DRIVER: log - CACHE_DRIVER: database - DB_CONNECTION: pgsql - DB_DATABASE: laravel - DB_HOST: database - # DB_PASSWORD: Injected into /run/secrets/DB_PASSWORD - DB_PORT: 5432 - DB_USERNAME: laravel - FILESYSTEM_DISK_PRIVATE: r2-private - FILESYSTEM_DISK_PUBLIC: r2-public - R2_PUBLIC_BUCKET: davidhartingdotcom-public - R2_PUBLIC_URL: https://cdn.davidharting.com - LOG_CHANNEL: stack - LOG_STACK: stderr,nightwatch - LOG_DEPRECATIONS_CHANNEL: null - LOG_LEVEL: debug - NIGHTWATCH_INGEST_URI: nightwatch-agent:2407 - NIGHTWATCH_REQUEST_SAMPLE_RATE: "1" - MAIL_FROM_ADDRESS: "hello@davidharting.com" - MAIL_FROM_NAME: davidharting.com - MAIL_LOG_CHANNEL: stderr - MAIL_MAILER: mailersend - OCTANE_HTTPS: true - OCTANE_SERVER: frankenphp - QUEUE_CONNECTION: database - SESSION_DRIVER: database - SESSION_LIFETIME: 120 - VITE_APP_NAME: davidharting.com - - depends_on: - database: - condition: service_healthy - restart: unless-stopped - stdin_open: true - tty: true - - deploy: - resources: - limits: - cpus: "0.25" - memory: 256M - reservations: - cpus: "0.1" - memory: 128M - -services: - database: - image: postgres:17.2 - secrets: - - DB_PASSWORD - environment: - POSTGRES_USER: laravel - POSTGRES_PASSWORD_FILE: /run/secrets/DB_PASSWORD - POSTGRES_DB: laravel - stdin_open: true - tty: true - healthcheck: - test: ["CMD-SHELL", "pg_isready -U laravel"] - interval: 1s - timeout: 5s - retries: 15 - volumes: - - db_data:/var/lib/postgresql/data - - deploy: - resources: - limits: - cpus: "0.25" - memory: 256M - reservations: - cpus: "0.1" - memory: 128M - - migrations: - <<: *laravel-config - command: ["php", "artisan", "migrate", "--force"] - restart: no - - web: - <<: *laravel-config - ports: - - "80:80" - - "443:443" - - "443:443/udp" - command: - [ - "php", - "artisan", - "octane:frankenphp", - "--caddyfile", - "Caddyfile", - "--https", - "--http-redirect", - "--host", - "davidharting.com", - ] - depends_on: - migrations: - condition: service_completed_successfully - - volumes: - - caddy_data:/data - - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost/healthz || exit 1"] - interval: 30s - timeout: 5s - retries: 5 - cron: - <<: *laravel-config - command: ["php", "artisan", "schedule:work"] - depends_on: - migrations: - condition: service_completed_successfully - healthcheck: - test: ["CMD-SHELL", 'pgrep -fl "php artisan schedule:work"'] - interval: 30s - timeout: 5s - retries: 5 - - worker: - <<: *laravel-config - command: ["php", "artisan", "queue:work", "-v"] - depends_on: - migrations: - condition: service_completed_successfully - - healthcheck: - test: ["CMD-SHELL", 'pgrep -fl "php artisan queue:work"'] - interval: 30s - timeout: 5s - retries: 5 - - configure-telegram: - <<: *laravel-config - command: - [ - "sh", - "-c", - "php artisan nutgram:hook:set https://davidharting.com/api/telegram/webhook && php artisan nutgram:register-commands", - ] - restart: no - depends_on: - migrations: - condition: service_completed_successfully - - nightwatch-agent: - image: laravelphp/nightwatch-agent:v1 - entrypoint: ["/bin/sh", "-c"] - command: - [ - "export NIGHTWATCH_TOKEN=$(cat /run/secrets/NIGHTWATCH_TOKEN) && exec ./entrypoint.sh", - ] - secrets: - - NIGHTWATCH_TOKEN - restart: unless-stopped - healthcheck: - test: php nightwatch-status - interval: 30s - timeout: 5s - retries: 3 - start_period: 5s From 07f03e2bd4ae50397ae3b972f11a1625fcdc4ef3 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:03:15 -0400 Subject: [PATCH 08/33] ci: remove GHCR image build workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/docker-image.yml | 39 ------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 5980351..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build and Push Docker Image - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64 - push: true - tags: | - ghcr.io/${{ github.repository }}:latest - ghcr.io/${{ github.repository }}:${{ github.sha }} From 6be7b581e8fa5d47fe884c6722d0a2f3def1c7e3 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:05:20 -0400 Subject: [PATCH 09/33] ci: run tests on push to main + align test DB name 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) --- .github/workflows/pr.yml | 10 ++++++++-- phpunit.xml | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d746f2d..0d81cf8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,9 +1,12 @@ -name: Pull Request CI +name: CI on: pull_request: branches: - main + push: + branches: + - main workflow_dispatch: # Allows you to run this workflow manually from the Actions tab jobs: @@ -51,6 +54,9 @@ jobs: run: php artisan key:generate - name: Execute tests + env: + DB_USERNAME: root + DB_PASSWORD: password run: php artisan test --compact --do-not-cache-result services: @@ -59,7 +65,7 @@ jobs: env: POSTGRES_USER: root POSTGRES_PASSWORD: password - POSTGRES_DB: laravel + POSTGRES_DB: laravel_test ports: - 5432:5432 options: >- diff --git a/phpunit.xml b/phpunit.xml index 2494e28..b5122b4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,8 +24,8 @@ - - + + From c361158843cb2e154e0128cb7bd19d09318642cf Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:05:55 -0400 Subject: [PATCH 10/33] docs: rewrite CLAUDE.md architecture section for Render 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) --- CLAUDE.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b418cb6..15060a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,19 +8,25 @@ Quick check: if `php artisan test` fails with autoload errors, run the setup. ## Architecture overview -- Cloudflare DNS. Orange-checkmark reverse proxy to my Digital Ocean droplet -- Digital Ocean droplet is running Ubuntu with Docker installed -- I use `docker-compose up` to run the containers for my site. See docker-compose.yml -- I run the Laravel web server Octane and Caddy. See Caddyfile. - -So Cloudflare -> Digital Ocean droplet -> Caddy -> Laravel -> Postgres - -The containers are: - -- laravel web server -- laravel queue worker -- laravel scheduler -- postgres database +The site runs on Render.com, provisioned from `render.yaml` (Blueprint-as-code). + +``` +Internet + └─ Render ingress (terminates TLS, region: ohio) + └─ web service → FrankenPHP Octane (HTTP on $PORT) + └─ worker service → php artisan queue:work + └─ scheduler worker → php artisan schedule:work + └─ Postgres (managed) ← private network + └─ Cloudflare R2 (public + private buckets) +``` + +- All services build from the repo `Dockerfile` (`runtime: docker`). Render does not share builds across services, so each of the three services builds the image separately; Dockerfile layer ordering is tuned so dependency layers stay cached. +- Render terminates TLS at the ingress — the container listens on plain HTTP at `$PORT`. The `Caddyfile` binds `:{$PORT:80}`. +- Shared env vars live in an `envVarGroups` block; secrets use `sync: false` (prompted once at blueprint creation, then managed via the Render dashboard). +- Database wiring is a single `DATABASE_URL` sourced from the managed Postgres via `fromDatabase.connectionString`. +- Migrations + Telegram webhook registration run in the web service `preDeployCommand`. If preDeploy fails, Render keeps the prior version live (zero-downtime). +- Cloudflare DNS is a follow-up — for now the site serves on a generated `*.onrender.com` URL. +- Historical note: was previously Cloudflare (orange-cloud) → Digital Ocean droplet running Docker Compose. See `docs/projects/render-migration.md` for migration history and follow-ups. ## Commands From 590ee7d06ca43482291434e3f6feb0f1280a0184 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:07:09 -0400 Subject: [PATCH 11/33] docs: add render-migration project runbook 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) --- docs/projects/render-migration.md | 163 ++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/projects/render-migration.md diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md new file mode 100644 index 0000000..2fa1f1b --- /dev/null +++ b/docs/projects/render-migration.md @@ -0,0 +1,163 @@ +--- +name: render-migration +status: in-progress +--- + +# Render Migration + +Migrating davidharting.com from a Digital Ocean droplet running Docker Compose to Render.com, driven by `render.yaml` (Blueprint-as-code). + +## Why + +- Simplify infra (no more VM patching, no Docker Compose orchestration on a single host). +- Go all-in on a single PaaS now that I work at Render. +- Keep secrets management, scaling, and deploy UX inside one platform. + +## Target architecture + +``` +Internet + └─ Render ingress (terminates TLS, region: ohio) + └─ web service → FrankenPHP Octane (HTTP on $PORT) + └─ worker service → php artisan queue:work + └─ scheduler worker → php artisan schedule:work + └─ Postgres (managed) ← private network + └─ Cloudflare R2 (public + private buckets — unchanged) +``` + +Reliability posture: + +- Managed Postgres paid tier includes PITR + 7-day logical backups. +- Storage autoscaling (+50% / round to 5 GB) when the DB hits 90% full. +- Existing Spatie `backup:run --only-db` hourly job continues to push dumps to R2 private bucket. +- Three independent recovery paths for DB. + +## Decisions + +| Area | Choice | +|------|--------| +| Build source | `runtime: docker`, Render builds from repo `Dockerfile` each deploy | +| Region | `ohio` (closest to Indianapolis) | +| Postgres | `basic-1gb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on | +| Scheduler | Long-running `worker` running `php artisan schedule:work` (NOT a Render cron — avoids CronJobV2 cold-start risk on hourly `backup:run` ticks) | +| Cache / session / queue | `database` driver, no Redis for v1 | +| Logging | `LOG_CHANNEL=stderr` only | +| Nightwatch | Dropped for v1, revisit later | +| DNS | Render-generated `*.onrender.com` URL for v1; Cloudflare custom domain is a follow-up | +| Env vars | One `envVarGroups` + per-service `DATABASE_URL` via `fromDatabase.connectionString` | +| Migrations | Web service `preDeployCommand` (Render orchestrates DB readiness, no `pg_isready` wait needed) | +| Telegram webhook | Also in web `preDeployCommand`; idempotent | +| CI gate | "After CI Checks Pass" auto-deploy in dashboard (post-provision); CI runs on `push: main` and `pull_request` | + +## Env var inventory + +Non-secret (in `envVarGroups.davidhartingdotcom-shared`): + +``` +APP_NAME, APP_ENV, APP_DEBUG, APP_URL, LOG_CHANNEL, LOG_LEVEL, +CACHE_DRIVER, SESSION_DRIVER, SESSION_LIFETIME, SESSION_SECURE_COOKIE, +QUEUE_CONNECTION, BROADCAST_DRIVER, DB_CONNECTION, +FILESYSTEM_DISK_PRIVATE, FILESYSTEM_DISK_PUBLIC, +R2_PUBLIC_BUCKET, R2_PUBLIC_URL, +MAIL_MAILER, MAIL_FROM_ADDRESS, MAIL_FROM_NAME, +OCTANE_SERVER, OCTANE_HTTPS +``` + +Secrets (`sync: false` — Render prompts once at blueprint creation): + +``` +APP_KEY ← paste the existing production key verbatim +MAILERSEND_API_KEY +MAILERSEND_ADMIN_ADDRESS +R2_ACCESS_KEY_ID +R2_SECRET_ACCESS_KEY +R2_ENDPOINT +R2_PRIVATE_BUCKET +TELEGRAM_TOKEN +TELEGRAM_DAVID_ID +ANTHROPIC_API_KEY +``` + +Per-service: + +``` +DATABASE_URL ← fromDatabase.connectionString (web, worker, scheduler) +``` + +**Critical**: `APP_KEY` MUST match the existing production key. A new random one invalidates all encrypted cookies, sessions, and any encrypted DB columns. + +## First-deploy steps + +1. Open the Render dashboard and create a new Blueprint pointing at this repo. +2. Render parses `render.yaml`, provisions the Postgres database, and prompts for all `sync: false` secrets. Paste values from the current DO droplet's `./secrets/*.txt` files. +3. First deploy sequence: + - DB provisions and becomes available. + - Web service builds (Dockerfile). + - Web `preDeployCommand` runs: `php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands`. Migrations apply to the empty DB; Telegram webhook is registered against the placeholder `APP_URL`. + - Octane starts, listens on `$PORT`. + - Worker and scheduler services build in parallel pipelines and come up when done. +4. In the dashboard, set **Auto-Deploy: After CI Checks Pass** on each of the three services. This makes Render wait on GitHub Actions' `CI` workflow. +5. Grab the generated hostname (e.g. `davidhartingdotcom-web.onrender.com`) and update `APP_URL` in the env group to `https://davidhartingdotcom-web.onrender.com`. Render redeploys; `preDeployCommand` re-registers the Telegram webhook at the real URL. + +## Data migration (during cutover window) + +1. From the DO droplet: + ``` + docker exec pg_dump -U laravel -Fc laravel > dump.dump + scp dump.dump $HOME/ + ``` +2. Copy the external Postgres connection string from Render's DB dashboard. +3. Restore: + ``` + pg_restore --clean --if-exists --no-owner -d "$RENDER_DB_URL" dump.dump + ``` +4. Re-run smoke tests (below). + +## Smoke tests + +After first deploy and after `APP_URL` update: + +1. `curl -i https://.onrender.com/healthz` → `200 OK`, **no `Set-Cookie` header** (proves /healthz is outside web middleware). +2. `curl -I https://.onrender.com/` → `200`, `Content-Type: text/html; charset=UTF-8`. +3. Browser to `/backend` → auth redirect; DevTools shows session cookie has `Secure` flag (proves TrustProxies + SESSION_SECURE_COOKIE). +4. Render web logs show structured stderr output and no "Log channel [stack]..." errors. +5. Telegram: send `/whoami` → bot responds. Confirms webhook re-registered and worker is processing nutgram. +6. `curl "https://api.telegram.org/bot$TELEGRAM_TOKEN/getWebhookInfo"` → URL matches `$APP_URL/api/telegram/webhook`. +7. From web shell: `php artisan backup:run --only-db` → completes, dump lands in R2 private bucket. +8. Postgres shell: `SELECT count(*) FROM sessions;` — grows with human traffic, not healthcheck polls. +9. Scheduler logs show `schedule:work` alive and ticking each minute; `backup:run` fires once per hour inside that. +10. Worker: `php artisan tinker` → dispatch a test job → worker log shows it processed. `failed_jobs` stays empty over 5 minutes. +11. Browse the site; confirm R2 CDN assets load from `cdn.davidharting.com`. + +## DNS cutover (follow-up, not v1) + +1. Add `davidharting.com` as a custom domain in Render web service. +2. In Cloudflare DNS, set the apex record as DNS-only (grey cloud) pointing at Render's CNAME target. Wait for Render to issue a cert. +3. After cert issued, optionally flip to proxied (orange cloud, Orange-to-Orange). +4. Update `APP_URL` in the env group to `https://davidharting.com`. Redeploy — preDeploy re-registers the Telegram webhook at the new URL. +5. Re-run smoke tests 1–6 against `https://davidharting.com`. + +## Rollback + +If the new deploy misbehaves before DNS cutover: + +- **Code rollback**: click "Rollback" on any service in the Render dashboard. Render re-promotes the previous deploy. +- **Full fallback**: the Digital Ocean droplet and its Postgres are still running during the overlap window. Reverting Cloudflare DNS to the droplet IP (if not yet flipped) returns traffic to the old stack. If DNS has been flipped, take down the Render service / redirect Cloudflare back to DO. +- **DB rollback** (after data migration): Render Postgres supports PITR. From the dashboard, trigger a point-in-time recovery to just before the cutover. + +## Deferred to v2 + +- Nightwatch re-introduction via Render log drains / metrics +- Custom domain + Cloudflare orange cloud (Orange-to-Orange) +- Consolidating worker + scheduler into fewer paid containers if cost matters +- Switching cache/session to Render Key Value (Redis) if DB contention shows up +- Decommissioning the DO droplet after a monitoring window (keep it running ~2 weeks as a safety net) + +## Status + +- [x] Phase 1: Code changes — TrustProxies, /healthz, Caddyfile, Dockerfile, CI, CLAUDE.md +- [ ] Phase 2: Write `render.yaml` +- [ ] Phase 3: Provision Render Blueprint + first deploy +- [ ] Phase 4: Data migration + smoke tests +- [ ] DNS cutover (follow-up) +- [ ] Decommission Digital Ocean droplet From e6f2204854156a7aa629c25c1383ed32038d32a2 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:08:03 -0400 Subject: [PATCH 12/33] add render.yaml blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- render.yaml | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 render.yaml diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..febf027 --- /dev/null +++ b/render.yaml @@ -0,0 +1,154 @@ +databases: + - name: davidhartingdotcom-db + databaseName: laravel + user: laravel + plan: basic-1gb + postgresMajorVersion: "17" + region: ohio + diskSizeGB: 1 + storageAutoscalingEnabled: true + +envVarGroups: + - name: davidhartingdotcom-shared + envVars: + # ---- Non-secret runtime config ---- + - key: APP_NAME + value: davidharting.com + - key: APP_ENV + value: production + - key: APP_DEBUG + value: "false" + # Replace with the real generated onrender.com URL after first provision, + # then again with https://davidharting.com after DNS cutover. + - key: APP_URL + value: https://TBD.onrender.com + + - key: LOG_CHANNEL + value: stderr + - key: LOG_LEVEL + value: info + + - key: CACHE_DRIVER + value: database + - key: SESSION_DRIVER + value: database + - key: SESSION_LIFETIME + value: "120" + - key: SESSION_SECURE_COOKIE + value: "true" + - key: QUEUE_CONNECTION + value: database + - key: BROADCAST_DRIVER + value: log + + - key: DB_CONNECTION + value: pgsql + + - key: FILESYSTEM_DISK_PRIVATE + value: r2-private + - key: FILESYSTEM_DISK_PUBLIC + value: r2-public + - key: R2_PUBLIC_BUCKET + value: davidhartingdotcom-public + - key: R2_PUBLIC_URL + value: https://cdn.davidharting.com + + - key: MAIL_MAILER + value: mailersend + - key: MAIL_FROM_ADDRESS + value: hello@davidharting.com + - key: MAIL_FROM_NAME + value: davidharting.com + + - key: OCTANE_SERVER + value: frankenphp + - key: OCTANE_HTTPS + value: "false" + + # ---- Secrets (sync: false — Render prompts once at blueprint creation) ---- + # After initial creation these are managed via the dashboard; blueprint + # updates IGNORE sync: false entries and will not re-prompt. + # Paste APP_KEY verbatim from the existing production .secrets/APP_KEY.txt — + # a new random value invalidates all encrypted cookies, sessions, and + # encrypted DB columns. + - key: APP_KEY + sync: false + - key: MAILERSEND_API_KEY + sync: false + - key: MAILERSEND_ADMIN_ADDRESS + sync: false + - key: R2_ACCESS_KEY_ID + sync: false + - key: R2_SECRET_ACCESS_KEY + sync: false + - key: R2_ENDPOINT + sync: false + - key: R2_PRIVATE_BUCKET + sync: false + - key: TELEGRAM_TOKEN + sync: false + - key: TELEGRAM_DAVID_ID + sync: false + - key: ANTHROPIC_API_KEY + sync: false + +services: + - name: davidhartingdotcom-web + type: web + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + healthCheckPath: /healthz + # --caddyfile=Caddyfile is kept because without it Octane generates its own + # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, + # encode, and log redaction. $PORT is substituted inside the Caddyfile via + # Caddy's native env syntax (:{$PORT:80}). + dockerCommand: php artisan octane:frankenphp --caddyfile=Caddyfile --host=0.0.0.0 --port=$PORT + # Migrations run once per deploy before the new version takes traffic. If + # any command fails, Render cancels the deploy with zero downtime. + # Telegram webhook registration is idempotent (setWebhook / setMyCommands + # are upserts on Telegram's side). + preDeployCommand: php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString + + - name: davidhartingdotcom-worker + type: worker + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + # --tries=3 --backoff=30 makes the worker resilient to the ~60s window + # where it might boot new code against the old DB schema (preDeploy runs + # only on the web service; worker has its own independent pipeline). + dockerCommand: php artisan queue:work -v --tries=3 --backoff=30 + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString + + - name: davidhartingdotcom-scheduler + # A worker (not a Render cron) so Laravel boots once and ticks every + # minute in-process. Render's cron service spawns a fresh Kubernetes pod + # per tick — with a ~700MB image and hourly pg_dump + R2 upload runs, + # a cold pull + boot + backup can stack toward 60s and either get + # skipped, overlapped, or killed depending on concurrencyPolicy. + type: worker + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + dockerCommand: php artisan schedule:work + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString From 953f1133480547713b804eb554b4f815e68a96da Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:16:37 -0400 Subject: [PATCH 13/33] bump PHP memory_limit to 256M MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b3233a2..c9992bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,8 @@ RUN install-php-extensions \ pdo_pgsql \ zip -RUN echo "upload_max_filesize = 25M\npost_max_size = 27M" \ - > /usr/local/etc/php/conf.d/uploads.ini +RUN printf "memory_limit = 256M\nupload_max_filesize = 25M\npost_max_size = 27M\n" \ + > /usr/local/etc/php/conf.d/php.ini RUN mkdir -p /app/public/build From b46bd742f660ce318830c84266d971613347b3fb Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:18:59 -0400 Subject: [PATCH 14/33] document memory_limit sizing rule in Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Dockerfile | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Dockerfile b/Dockerfile index c9992bd..1c3f498 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,24 @@ RUN install-php-extensions \ pdo_pgsql \ zip +# PHP runtime config. +# +# memory_limit: the dunglas/frankenphp base image does not override php.ini, so +# by default we'd inherit PHP's compile-time default of 128M. Bumped to 256M +# to give Octane worker threads headroom for Filament exports and import +# actions. +# +# Sizing rule from FrankenPHP's performance docs: +# num_threads × memory_limit < available_memory +# FrankenPHP spawns 2 × CPU cores worker threads by default. On the Render +# starter plan (0.5 CPU, 512 MB) that's ~1 thread, so 256M fits comfortably +# alongside Caddy + Go runtime overhead. If we move to a larger Render plan, +# revisit this — on standard (1 CPU, 2 GB) the default 2 threads × 256M = 512M +# is still fine, but on pro (2 CPU, 4 GB) with 4 threads we'd use 1 GB for PHP. +# +# The worker / scheduler services run a single PHP process (queue:work / +# schedule:work) so the thread multiplier doesn't apply — 256M is strictly +# safe there. RUN printf "memory_limit = 256M\nupload_max_filesize = 25M\npost_max_size = 27M\n" \ > /usr/local/etc/php/conf.d/php.ini From 8936ff6d8b41c9afd7083c1315f56adad4041158 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:41:08 -0400 Subject: [PATCH 15/33] docs: add local Dockerfile smoke-test section to runbook 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) --- docs/projects/render-migration.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md index 2fa1f1b..b415c1b 100644 --- a/docs/projects/render-migration.md +++ b/docs/projects/render-migration.md @@ -113,6 +113,24 @@ DATABASE_URL ← fromDatabase.connectionString (web, worker, sched ``` 4. Re-run smoke tests (below). +## Local Dockerfile smoke test + +To validate the image locally before pushing: + +``` +docker build -t davidhartingdotcom:local . +docker run --rm -d --name render-test \ + -e PORT=9090 -p 9090:9090 \ + -e APP_KEY=base64:... -e APP_ENV=production -e APP_DEBUG=false \ + -e DB_CONNECTION=sqlite -e DB_DATABASE=:memory: \ + -e CACHE_DRIVER=array -e SESSION_DRIVER=array -e QUEUE_CONNECTION=sync \ + -e LOG_CHANNEL=stderr \ + davidhartingdotcom:local +curl -i http://localhost:9090/healthz # expect 200 OK, body "OK", no Set-Cookie +``` + +**Pick a known-free port** and check `lsof -iTCP: -sTCP:LISTEN` first. Common ports (8080, 8000) are often squatted by other dev tools (tilt, ssh tunnels, forwarded Docker Desktop mappings) that intercept traffic and can surface as unrelated-looking errors (e.g. spurious HTTPS redirects from whatever's actually listening). + ## Smoke tests After first deploy and after `APP_URL` update: From 406ac228610fad03fe035330e103dace951878b6 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:44:02 -0400 Subject: [PATCH 16/33] render.yaml: wrap services in project + prod environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- render.yaml | 290 ++++++++++++++++++++++++++-------------------------- 1 file changed, 147 insertions(+), 143 deletions(-) diff --git a/render.yaml b/render.yaml index febf027..caa92fd 100644 --- a/render.yaml +++ b/render.yaml @@ -1,154 +1,158 @@ -databases: - - name: davidhartingdotcom-db - databaseName: laravel - user: laravel - plan: basic-1gb - postgresMajorVersion: "17" - region: ohio - diskSizeGB: 1 - storageAutoscalingEnabled: true +projects: + - name: davidhartingdotcom + environments: + - name: prod + databases: + - name: davidhartingdotcom-db + databaseName: laravel + user: laravel + plan: basic-1gb + postgresMajorVersion: "17" + region: ohio + diskSizeGB: 1 + storageAutoscalingEnabled: true -envVarGroups: - - name: davidhartingdotcom-shared - envVars: - # ---- Non-secret runtime config ---- - - key: APP_NAME - value: davidharting.com - - key: APP_ENV - value: production - - key: APP_DEBUG - value: "false" - # Replace with the real generated onrender.com URL after first provision, - # then again with https://davidharting.com after DNS cutover. - - key: APP_URL - value: https://TBD.onrender.com + envVarGroups: + - name: davidhartingdotcom-shared + envVars: + # ---- Non-secret runtime config ---- + - key: APP_NAME + value: davidharting.com + - key: APP_ENV + value: production + - key: APP_DEBUG + value: "false" + # Replace with the real generated onrender.com URL after first provision, + # then again with https://davidharting.com after DNS cutover. + - key: APP_URL + value: https://TBD.onrender.com - - key: LOG_CHANNEL - value: stderr - - key: LOG_LEVEL - value: info + - key: LOG_CHANNEL + value: stderr + - key: LOG_LEVEL + value: info - - key: CACHE_DRIVER - value: database - - key: SESSION_DRIVER - value: database - - key: SESSION_LIFETIME - value: "120" - - key: SESSION_SECURE_COOKIE - value: "true" - - key: QUEUE_CONNECTION - value: database - - key: BROADCAST_DRIVER - value: log + - key: CACHE_DRIVER + value: database + - key: SESSION_DRIVER + value: database + - key: SESSION_LIFETIME + value: "120" + - key: SESSION_SECURE_COOKIE + value: "true" + - key: QUEUE_CONNECTION + value: database + - key: BROADCAST_DRIVER + value: log - - key: DB_CONNECTION - value: pgsql + - key: DB_CONNECTION + value: pgsql - - key: FILESYSTEM_DISK_PRIVATE - value: r2-private - - key: FILESYSTEM_DISK_PUBLIC - value: r2-public - - key: R2_PUBLIC_BUCKET - value: davidhartingdotcom-public - - key: R2_PUBLIC_URL - value: https://cdn.davidharting.com + - key: FILESYSTEM_DISK_PRIVATE + value: r2-private + - key: FILESYSTEM_DISK_PUBLIC + value: r2-public + - key: R2_PUBLIC_BUCKET + value: davidhartingdotcom-public + - key: R2_PUBLIC_URL + value: https://cdn.davidharting.com - - key: MAIL_MAILER - value: mailersend - - key: MAIL_FROM_ADDRESS - value: hello@davidharting.com - - key: MAIL_FROM_NAME - value: davidharting.com + - key: MAIL_MAILER + value: mailersend + - key: MAIL_FROM_ADDRESS + value: hello@davidharting.com + - key: MAIL_FROM_NAME + value: davidharting.com - - key: OCTANE_SERVER - value: frankenphp - - key: OCTANE_HTTPS - value: "false" + - key: OCTANE_SERVER + value: frankenphp + - key: OCTANE_HTTPS + value: "false" - # ---- Secrets (sync: false — Render prompts once at blueprint creation) ---- - # After initial creation these are managed via the dashboard; blueprint - # updates IGNORE sync: false entries and will not re-prompt. - # Paste APP_KEY verbatim from the existing production .secrets/APP_KEY.txt — - # a new random value invalidates all encrypted cookies, sessions, and - # encrypted DB columns. - - key: APP_KEY - sync: false - - key: MAILERSEND_API_KEY - sync: false - - key: MAILERSEND_ADMIN_ADDRESS - sync: false - - key: R2_ACCESS_KEY_ID - sync: false - - key: R2_SECRET_ACCESS_KEY - sync: false - - key: R2_ENDPOINT - sync: false - - key: R2_PRIVATE_BUCKET - sync: false - - key: TELEGRAM_TOKEN - sync: false - - key: TELEGRAM_DAVID_ID - sync: false - - key: ANTHROPIC_API_KEY - sync: false + # ---- Secrets (sync: false — Render prompts once at blueprint creation) ---- + # After initial creation these are managed via the dashboard; blueprint + # updates IGNORE sync: false entries and will not re-prompt. + # Paste APP_KEY verbatim from the existing production .secrets/APP_KEY.txt — + # a new random value invalidates all encrypted cookies, sessions, and + # encrypted DB columns. + - key: APP_KEY + sync: false + - key: MAILERSEND_API_KEY + sync: false + - key: MAILERSEND_ADMIN_ADDRESS + sync: false + - key: R2_ACCESS_KEY_ID + sync: false + - key: R2_SECRET_ACCESS_KEY + sync: false + - key: R2_ENDPOINT + sync: false + - key: R2_PRIVATE_BUCKET + sync: false + - key: TELEGRAM_TOKEN + sync: false + - key: TELEGRAM_DAVID_ID + sync: false + - key: ANTHROPIC_API_KEY + sync: false -services: - - name: davidhartingdotcom-web - type: web - runtime: docker - plan: starter - region: ohio - dockerfilePath: ./Dockerfile - healthCheckPath: /healthz - # --caddyfile=Caddyfile is kept because without it Octane generates its own - # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, - # encode, and log redaction. $PORT is substituted inside the Caddyfile via - # Caddy's native env syntax (:{$PORT:80}). - dockerCommand: php artisan octane:frankenphp --caddyfile=Caddyfile --host=0.0.0.0 --port=$PORT - # Migrations run once per deploy before the new version takes traffic. If - # any command fails, Render cancels the deploy with zero downtime. - # Telegram webhook registration is idempotent (setWebhook / setMyCommands - # are upserts on Telegram's side). - preDeployCommand: php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands - envVars: - - fromGroup: davidhartingdotcom-shared - - key: DATABASE_URL - fromDatabase: - name: davidhartingdotcom-db - property: connectionString + services: + - name: davidhartingdotcom-web + type: web + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + healthCheckPath: /healthz + # --caddyfile=Caddyfile is kept because without it Octane generates its own + # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, + # encode, and log redaction. $PORT is substituted inside the Caddyfile via + # Caddy's native env syntax (:{$PORT:80}). + dockerCommand: php artisan octane:frankenphp --caddyfile=Caddyfile --host=0.0.0.0 --port=$PORT + # Migrations run once per deploy before the new version takes traffic. If + # any command fails, Render cancels the deploy with zero downtime. + # Telegram webhook registration is idempotent (setWebhook / setMyCommands + # are upserts on Telegram's side). + preDeployCommand: php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString - - name: davidhartingdotcom-worker - type: worker - runtime: docker - plan: starter - region: ohio - dockerfilePath: ./Dockerfile - # --tries=3 --backoff=30 makes the worker resilient to the ~60s window - # where it might boot new code against the old DB schema (preDeploy runs - # only on the web service; worker has its own independent pipeline). - dockerCommand: php artisan queue:work -v --tries=3 --backoff=30 - envVars: - - fromGroup: davidhartingdotcom-shared - - key: DATABASE_URL - fromDatabase: - name: davidhartingdotcom-db - property: connectionString + - name: davidhartingdotcom-worker + type: worker + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + # --tries=3 --backoff=30 makes the worker resilient to the ~60s window + # where it might boot new code against the old DB schema (preDeploy runs + # only on the web service; worker has its own independent pipeline). + dockerCommand: php artisan queue:work -v --tries=3 --backoff=30 + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString - - name: davidhartingdotcom-scheduler - # A worker (not a Render cron) so Laravel boots once and ticks every - # minute in-process. Render's cron service spawns a fresh Kubernetes pod - # per tick — with a ~700MB image and hourly pg_dump + R2 upload runs, - # a cold pull + boot + backup can stack toward 60s and either get - # skipped, overlapped, or killed depending on concurrencyPolicy. - type: worker - runtime: docker - plan: starter - region: ohio - dockerfilePath: ./Dockerfile - dockerCommand: php artisan schedule:work - envVars: - - fromGroup: davidhartingdotcom-shared - - key: DATABASE_URL - fromDatabase: - name: davidhartingdotcom-db - property: connectionString + - name: davidhartingdotcom-scheduler + # A worker (not a Render cron) so Laravel boots once and ticks every + # minute in-process. Render's cron service spawns a fresh Kubernetes pod + # per tick — with a ~700MB image and hourly pg_dump + R2 upload runs, + # a cold pull + boot + backup can stack toward 60s and either get + # skipped, overlapped, or killed depending on concurrencyPolicy. + type: worker + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + dockerCommand: php artisan schedule:work + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString From 6d90d270774b6782882bb9a77270c9dfa8f2cf02 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:44:30 -0400 Subject: [PATCH 17/33] render.yaml: downgrade Postgres to basic-256mb 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) --- docs/projects/render-migration.md | 2 +- render.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md index b415c1b..1948951 100644 --- a/docs/projects/render-migration.md +++ b/docs/projects/render-migration.md @@ -38,7 +38,7 @@ Reliability posture: |------|--------| | Build source | `runtime: docker`, Render builds from repo `Dockerfile` each deploy | | Region | `ohio` (closest to Indianapolis) | -| Postgres | `basic-1gb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on | +| Postgres | `basic-256mb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on — still a paid tier so PITR + 7-day logical backups are included | | Scheduler | Long-running `worker` running `php artisan schedule:work` (NOT a Render cron — avoids CronJobV2 cold-start risk on hourly `backup:run` ticks) | | Cache / session / queue | `database` driver, no Redis for v1 | | Logging | `LOG_CHANNEL=stderr` only | diff --git a/render.yaml b/render.yaml index caa92fd..197b843 100644 --- a/render.yaml +++ b/render.yaml @@ -6,7 +6,7 @@ projects: - name: davidhartingdotcom-db databaseName: laravel user: laravel - plan: basic-1gb + plan: basic-256mb postgresMajorVersion: "17" region: ohio diskSizeGB: 1 From ba696cb26d6481947c1d7af87fa4936e1b391803 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 11:45:43 -0400 Subject: [PATCH 18/33] docs: tick off completed migration phases in runbook - 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) --- docs/projects/render-migration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md index 1948951..e36d074 100644 --- a/docs/projects/render-migration.md +++ b/docs/projects/render-migration.md @@ -174,8 +174,9 @@ If the new deploy misbehaves before DNS cutover: ## Status - [x] Phase 1: Code changes — TrustProxies, /healthz, Caddyfile, Dockerfile, CI, CLAUDE.md -- [ ] Phase 2: Write `render.yaml` -- [ ] Phase 3: Provision Render Blueprint + first deploy +- [x] Phase 2: Write `render.yaml` — project + prod environment, Postgres 17 basic-256mb, three Docker services, DATABASE_URL wiring, preDeploy for migrations + Telegram webhook +- [x] Local Dockerfile smoke test — image builds, Octane boots, `/healthz` returns 200 with no `Set-Cookie` +- [ ] Phase 3: Provision Render Blueprint + first deploy (manual, from dashboard) - [ ] Phase 4: Data migration + smoke tests - [ ] DNS cutover (follow-up) - [ ] Decommission Digital Ocean droplet From 03ddf73826573fb25d943dee81d2d51b5da26f69 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 12:06:19 -0400 Subject: [PATCH 19/33] fix: run preDeployCommand through a shell script 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 --- render.yaml | 2 +- scripts/predeploy.sh | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 scripts/predeploy.sh diff --git a/render.yaml b/render.yaml index 197b843..08d25de 100644 --- a/render.yaml +++ b/render.yaml @@ -113,7 +113,7 @@ projects: # any command fails, Render cancels the deploy with zero downtime. # Telegram webhook registration is idempotent (setWebhook / setMyCommands # are upserts on Telegram's side). - preDeployCommand: php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands + preDeployCommand: bash /app/scripts/predeploy.sh envVars: - fromGroup: davidhartingdotcom-shared - key: DATABASE_URL diff --git a/scripts/predeploy.sh b/scripts/predeploy.sh new file mode 100644 index 0000000..31e580d --- /dev/null +++ b/scripts/predeploy.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +php artisan migrate --force +php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" +php artisan nutgram:register-commands From 08220d1ad64340471b5b62af40f4d8ce750b311a Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 15:27:37 -0400 Subject: [PATCH 20/33] fix: strip file capabilities from frankenphp binary for Render compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dunglas/frankenphp base image sets cap_net_bind_service+ep on the frankenphp binary via setcap, so the process can bind to privileged ports (<1024) without running as root. Render's container runtime drops file capabilities by default (standard hardening in Kubernetes-based runtimes). When the kernel sees an exec of a capability-bearing binary inside a container that hasn't been granted CAP_SETFCAP or equivalent, it returns EPERM — which showed up as: sh: 1: exec: /usr/local/bin/frankenphp: Operation not permitted (exit 126) The fix: install libcap2-bin (which provides the setcap/getcap tools) and immediately run `setcap -r /usr/local/bin/frankenphp` to revoke all file capabilities from the binary. Since Render terminates TLS at its ingress and injects a high-numbered $PORT (10000+), we never need cap_net_bind_service anyway — that capability only matters when binding directly to port 80/443. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c3f498..d670950 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,9 @@ RUN apt-get update \ && echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ && apt-get upgrade -y \ - && apt-get install -y unzip libnss3-tools procps postgresql-client-17 nodejs \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y unzip libnss3-tools procps postgresql-client-17 nodejs libcap2-bin \ + && rm -rf /var/lib/apt/lists/* \ + && setcap -r /usr/local/bin/frankenphp RUN install-php-extensions \ intl \ From 17ce78baae7d86c8f6f93cea3514fb7433a43ff4 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 15:33:53 -0400 Subject: [PATCH 21/33] render.yaml: set APP_URL to generated onrender.com hostname Replaces the TBD placeholder with the real URL now that the service has been provisioned. A redeploy will re-register the Telegram webhook at the correct address. Co-Authored-By: Claude Sonnet 4.6 --- render.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index 08d25de..56d3611 100644 --- a/render.yaml +++ b/render.yaml @@ -25,7 +25,7 @@ projects: # Replace with the real generated onrender.com URL after first provision, # then again with https://davidharting.com after DNS cutover. - key: APP_URL - value: https://TBD.onrender.com + value: https://davidhartingdotcom-web.onrender.com - key: LOG_CHANNEL value: stderr From 38a7440cf4b9e80b125ec631dd72d424d068d7e1 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 24 Apr 2026 15:42:28 -0400 Subject: [PATCH 22/33] docs: flesh out data migration plan and tick off Phase 3 - Expanded data migration section with: pre-window checklist, how to find the DO DB container name, SSH pipe trick to avoid a separate scp step, pg_restore flag rationale (--no-owner, --no-privileges), and a quick row-count sanity check after restore - Marked Phase 3 complete with a note on the three fixes required post-provision (predeploy shell wrap, setcap -r, dashboard secrets) Co-Authored-By: Claude Sonnet 4.6 --- docs/projects/render-migration.md | 49 ++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md index e36d074..37da2de 100644 --- a/docs/projects/render-migration.md +++ b/docs/projects/render-migration.md @@ -101,17 +101,49 @@ DATABASE_URL ← fromDatabase.connectionString (web, worker, sched ## Data migration (during cutover window) -1. From the DO droplet: +### Before you open the maintenance window + +1. Run smoke tests 1–4 against the live Render URL with the empty database to confirm the app boots, serves HTML, and auth redirects work. This isolates any Render-specific issues from data issues. +2. Get the Render **external** Postgres connection string: Dashboard → `davidhartingdotcom-db` → Info → External Connection String. It looks like `postgresql://laravel:@dpg-xxx.ohio-postgres.render.com/laravel`. Save it as `$RENDER_DB_URL` in your shell. +3. Confirm you can reach the DO droplet via SSH. +4. Find the DB container name on the droplet: + ``` + ssh docker ps --format '{{.Names}}' | grep -i db ``` - docker exec pg_dump -U laravel -Fc laravel > dump.dump - scp dump.dump $HOME/ + It will be something like `davidharting-com-db-1`. + +### Migration window + +The window is the gap between the final dump and flipping traffic to Render. For a low-traffic personal site a few minutes of read-only or brief downtime is fine — no formal maintenance page needed. + +1. **Take the dump on the DO droplet:** + ```bash + ssh \ + "docker exec pg_dump -U laravel -Fc laravel" \ + > dump.dump ``` -2. Copy the external Postgres connection string from Render's DB dashboard. -3. Restore: + This pipes the dump over SSH directly to your local machine — no intermediate `scp` step. + +2. **Restore to Render Postgres:** + ```bash + pg_restore \ + --clean --if-exists \ + --no-owner --no-privileges \ + -d "$RENDER_DB_URL" \ + dump.dump ``` - pg_restore --clean --if-exists --no-owner -d "$RENDER_DB_URL" dump.dump + Flag rationale: + - `--clean --if-exists` — drops objects before recreating; handles the schema that `preDeploy` already created + - `--no-owner` — skips `ALTER TABLE ... OWNER TO laravel`; Render's DB user is different + - `--no-privileges` — skips `GRANT/REVOKE` statements that reference the DO role + +3. **Verify row counts look right** (quick sanity check): + ```bash + psql "$RENDER_DB_URL" -c "\dt+" | head -30 + psql "$RENDER_DB_URL" -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;" ``` -4. Re-run smoke tests (below). + +4. Re-run the full smoke test checklist below. ## Local Dockerfile smoke test @@ -176,7 +208,8 @@ If the new deploy misbehaves before DNS cutover: - [x] Phase 1: Code changes — TrustProxies, /healthz, Caddyfile, Dockerfile, CI, CLAUDE.md - [x] Phase 2: Write `render.yaml` — project + prod environment, Postgres 17 basic-256mb, three Docker services, DATABASE_URL wiring, preDeploy for migrations + Telegram webhook - [x] Local Dockerfile smoke test — image builds, Octane boots, `/healthz` returns 200 with no `Set-Cookie` -- [ ] Phase 3: Provision Render Blueprint + first deploy (manual, from dashboard) +- [x] Phase 3: Provision Render Blueprint + first deploy — service live at `https://davidhartingdotcom-web.onrender.com` + - Fixes required post-provision: shell-wrap preDeployCommand (`bash scripts/predeploy.sh`), strip `cap_net_bind_service` from frankenphp binary (`setcap -r`), manually set `sync: false` secrets in dashboard - [ ] Phase 4: Data migration + smoke tests - [ ] DNS cutover (follow-up) - [ ] Decommission Digital Ocean droplet From b99612be903b7999d8e53c1bc6dc6625d0fd8d37 Mon Sep 17 00:00:00 2001 From: David Harting Date: Mon, 27 Apr 2026 10:21:41 -0400 Subject: [PATCH 23/33] render.yaml: add davidharting.com custom domain to web service Co-Authored-By: Claude Sonnet 4.6 --- render.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/render.yaml b/render.yaml index 56d3611..1c91537 100644 --- a/render.yaml +++ b/render.yaml @@ -104,6 +104,8 @@ projects: region: ohio dockerfilePath: ./Dockerfile healthCheckPath: /healthz + domains: + - davidharting.com # --caddyfile=Caddyfile is kept because without it Octane generates its own # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, # encode, and log redaction. $PORT is substituted inside the Caddyfile via From c80980d91a8f7427f620371cfc86b4526671ccc7 Mon Sep 17 00:00:00 2001 From: David Harting Date: Mon, 27 Apr 2026 10:34:37 -0400 Subject: [PATCH 24/33] render.yaml: update APP_URL to davidharting.com after DNS cutover Co-Authored-By: Claude Sonnet 4.6 --- render.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index 1c91537..7516e38 100644 --- a/render.yaml +++ b/render.yaml @@ -25,7 +25,7 @@ projects: # Replace with the real generated onrender.com URL after first provision, # then again with https://davidharting.com after DNS cutover. - key: APP_URL - value: https://davidhartingdotcom-web.onrender.com + value: https://davidharting.com - key: LOG_CHANNEL value: stderr From 838fdbd4fe10212bb89094b4163c85d2f57d8b00 Mon Sep 17 00:00:00 2001 From: David Harting Date: Mon, 27 Apr 2026 10:40:24 -0400 Subject: [PATCH 25/33] feat: replace always-on scheduler worker with Render native cron jobs The scheduler worker ran php artisan schedule:work 24/7 to handle two tasks: backup:run --only-db (hourly) and backup:clean (daily). Replacing it with two Render CronJobV2 services saves the cost of a continuously running container for essentially idle work. The original concern about CronJobV2 cold-start risk was based on a ~700MB image with hourly pg_dump + R2 upload potentially stacking toward 60s. In practice the database is ~156KB so the actual backup completes well under that, making cron a safe choice. Changes: - render.yaml: remove davidhartingdotcom-scheduler worker, add davidhartingdotcom-backup-run (0 * * * *) and davidhartingdotcom-backup-clean (0 0 * * *) cron services - routes/console.php: remove Schedule:: calls and unused Schedule import - docs: update decisions table to reflect cron approach Co-Authored-By: Claude Sonnet 4.6 --- docs/projects/render-migration.md | 2 +- render.yaml | 27 +++++++++++++++++++-------- routes/console.php | 4 ---- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md index 37da2de..728dc90 100644 --- a/docs/projects/render-migration.md +++ b/docs/projects/render-migration.md @@ -39,7 +39,7 @@ Reliability posture: | Build source | `runtime: docker`, Render builds from repo `Dockerfile` each deploy | | Region | `ohio` (closest to Indianapolis) | | Postgres | `basic-256mb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on — still a paid tier so PITR + 7-day logical backups are included | -| Scheduler | Long-running `worker` running `php artisan schedule:work` (NOT a Render cron — avoids CronJobV2 cold-start risk on hourly `backup:run` ticks) | +| Scheduler | Two Render cron jobs: `backup:run --only-db` hourly, `backup:clean` daily (switched from always-on `schedule:work` worker to save cost; DB is small enough that cold-start + backup completes well under 60s) | | Cache / session / queue | `database` driver, no Redis for v1 | | Logging | `LOG_CHANNEL=stderr` only | | Nightwatch | Dropped for v1, revisit later | diff --git a/render.yaml b/render.yaml index 7516e38..8bad65e 100644 --- a/render.yaml +++ b/render.yaml @@ -140,18 +140,29 @@ projects: name: davidhartingdotcom-db property: connectionString - - name: davidhartingdotcom-scheduler - # A worker (not a Render cron) so Laravel boots once and ticks every - # minute in-process. Render's cron service spawns a fresh Kubernetes pod - # per tick — with a ~700MB image and hourly pg_dump + R2 upload runs, - # a cold pull + boot + backup can stack toward 60s and either get - # skipped, overlapped, or killed depending on concurrencyPolicy. - type: worker + - name: davidhartingdotcom-backup-run + type: cron + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + schedule: "0 * * * *" + dockerCommand: php artisan backup:run --only-db + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString + + - name: davidhartingdotcom-backup-clean + type: cron runtime: docker plan: starter region: ohio dockerfilePath: ./Dockerfile - dockerCommand: php artisan schedule:work + schedule: "0 0 * * *" + dockerCommand: php artisan backup:clean envVars: - fromGroup: davidhartingdotcom-shared - key: DATABASE_URL diff --git a/routes/console.php b/routes/console.php index 090cd54..e05f4c9 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,6 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Schedule; /* |-------------------------------------------------------------------------- @@ -18,6 +17,3 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); - -Schedule::command('backup:clean')->daily()->sendOutputTo('/dev/stderr'); -Schedule::command('backup:run --only-db')->hourly()->sendOutputTo('/dev/stderr'); From 53317a6485831882a4ee2d0a5ce7d31dbc88415a Mon Sep 17 00:00:00 2001 From: David Harting Date: Mon, 27 Apr 2026 10:47:51 -0400 Subject: [PATCH 26/33] Disable Render subdomain in blueprint --- render.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/render.yaml b/render.yaml index 8bad65e..a39b9c6 100644 --- a/render.yaml +++ b/render.yaml @@ -106,6 +106,7 @@ projects: healthCheckPath: /healthz domains: - davidharting.com + renderSubdomainPolicy: disabled # --caddyfile=Caddyfile is kept because without it Octane generates its own # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, # encode, and log redaction. $PORT is substituted inside the Caddyfile via From d45b6d232006df6a4bd5f73d76a74a26558bebdf Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 12:15:30 -0400 Subject: [PATCH 27/33] format --- docs/projects/render-migration.md | 95 ++++++++++--------- .../Unit/Http/Middleware/TrustProxiesTest.php | 5 +- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md index 728dc90..b3ddd93 100644 --- a/docs/projects/render-migration.md +++ b/docs/projects/render-migration.md @@ -34,20 +34,20 @@ Reliability posture: ## Decisions -| Area | Choice | -|------|--------| -| Build source | `runtime: docker`, Render builds from repo `Dockerfile` each deploy | -| Region | `ohio` (closest to Indianapolis) | -| Postgres | `basic-256mb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on — still a paid tier so PITR + 7-day logical backups are included | -| Scheduler | Two Render cron jobs: `backup:run --only-db` hourly, `backup:clean` daily (switched from always-on `schedule:work` worker to save cost; DB is small enough that cold-start + backup completes well under 60s) | -| Cache / session / queue | `database` driver, no Redis for v1 | -| Logging | `LOG_CHANNEL=stderr` only | -| Nightwatch | Dropped for v1, revisit later | -| DNS | Render-generated `*.onrender.com` URL for v1; Cloudflare custom domain is a follow-up | -| Env vars | One `envVarGroups` + per-service `DATABASE_URL` via `fromDatabase.connectionString` | -| Migrations | Web service `preDeployCommand` (Render orchestrates DB readiness, no `pg_isready` wait needed) | -| Telegram webhook | Also in web `preDeployCommand`; idempotent | -| CI gate | "After CI Checks Pass" auto-deploy in dashboard (post-provision); CI runs on `push: main` and `pull_request` | +| Area | Choice | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Build source | `runtime: docker`, Render builds from repo `Dockerfile` each deploy | +| Region | `ohio` (closest to Indianapolis) | +| Postgres | `basic-256mb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on — still a paid tier so PITR + 7-day logical backups are included | +| Scheduler | Two Render cron jobs: `backup:run --only-db` hourly, `backup:clean` daily (switched from always-on `schedule:work` worker to save cost; DB is small enough that cold-start + backup completes well under 60s) | +| Cache / session / queue | `database` driver, no Redis for v1 | +| Logging | `LOG_CHANNEL=stderr` only | +| Nightwatch | Dropped for v1, revisit later | +| DNS | Render-generated `*.onrender.com` URL for v1; Cloudflare custom domain is a follow-up | +| Env vars | One `envVarGroups` + per-service `DATABASE_URL` via `fromDatabase.connectionString` | +| Migrations | Web service `preDeployCommand` (Render orchestrates DB readiness, no `pg_isready` wait needed) | +| Telegram webhook | Also in web `preDeployCommand`; idempotent | +| CI gate | "After CI Checks Pass" auto-deploy in dashboard (post-provision); CI runs on `push: main` and `pull_request` | ## Env var inventory @@ -91,11 +91,11 @@ DATABASE_URL ← fromDatabase.connectionString (web, worker, sched 1. Open the Render dashboard and create a new Blueprint pointing at this repo. 2. Render parses `render.yaml`, provisions the Postgres database, and prompts for all `sync: false` secrets. Paste values from the current DO droplet's `./secrets/*.txt` files. 3. First deploy sequence: - - DB provisions and becomes available. - - Web service builds (Dockerfile). - - Web `preDeployCommand` runs: `php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands`. Migrations apply to the empty DB; Telegram webhook is registered against the placeholder `APP_URL`. - - Octane starts, listens on `$PORT`. - - Worker and scheduler services build in parallel pipelines and come up when done. + - DB provisions and becomes available. + - Web service builds (Dockerfile). + - Web `preDeployCommand` runs: `php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands`. Migrations apply to the empty DB; Telegram webhook is registered against the placeholder `APP_URL`. + - Octane starts, listens on `$PORT`. + - Worker and scheduler services build in parallel pipelines and come up when done. 4. In the dashboard, set **Auto-Deploy: After CI Checks Pass** on each of the three services. This makes Render wait on GitHub Actions' `CI` workflow. 5. Grab the generated hostname (e.g. `davidhartingdotcom-web.onrender.com`) and update `APP_URL` in the env group to `https://davidhartingdotcom-web.onrender.com`. Render redeploys; `preDeployCommand` re-registers the Telegram webhook at the real URL. @@ -107,41 +107,46 @@ DATABASE_URL ← fromDatabase.connectionString (web, worker, sched 2. Get the Render **external** Postgres connection string: Dashboard → `davidhartingdotcom-db` → Info → External Connection String. It looks like `postgresql://laravel:@dpg-xxx.ohio-postgres.render.com/laravel`. Save it as `$RENDER_DB_URL` in your shell. 3. Confirm you can reach the DO droplet via SSH. 4. Find the DB container name on the droplet: - ``` - ssh docker ps --format '{{.Names}}' | grep -i db - ``` - It will be something like `davidharting-com-db-1`. + ``` + ssh docker ps --format '{{.Names}}' | grep -i db + ``` + It will be something like `davidharting-com-db-1`. ### Migration window The window is the gap between the final dump and flipping traffic to Render. For a low-traffic personal site a few minutes of read-only or brief downtime is fine — no formal maintenance page needed. 1. **Take the dump on the DO droplet:** - ```bash - ssh \ - "docker exec pg_dump -U laravel -Fc laravel" \ - > dump.dump - ``` - This pipes the dump over SSH directly to your local machine — no intermediate `scp` step. + + ```bash + ssh \ + "docker exec pg_dump -U laravel -Fc laravel" \ + > dump.dump + ``` + + This pipes the dump over SSH directly to your local machine — no intermediate `scp` step. 2. **Restore to Render Postgres:** - ```bash - pg_restore \ - --clean --if-exists \ - --no-owner --no-privileges \ - -d "$RENDER_DB_URL" \ - dump.dump - ``` - Flag rationale: - - `--clean --if-exists` — drops objects before recreating; handles the schema that `preDeploy` already created - - `--no-owner` — skips `ALTER TABLE ... OWNER TO laravel`; Render's DB user is different - - `--no-privileges` — skips `GRANT/REVOKE` statements that reference the DO role + + ```bash + pg_restore \ + --clean --if-exists \ + --no-owner --no-privileges \ + -d "$RENDER_DB_URL" \ + dump.dump + ``` + + Flag rationale: + - `--clean --if-exists` — drops objects before recreating; handles the schema that `preDeploy` already created + - `--no-owner` — skips `ALTER TABLE ... OWNER TO laravel`; Render's DB user is different + - `--no-privileges` — skips `GRANT/REVOKE` statements that reference the DO role 3. **Verify row counts look right** (quick sanity check): - ```bash - psql "$RENDER_DB_URL" -c "\dt+" | head -30 - psql "$RENDER_DB_URL" -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;" - ``` + + ```bash + psql "$RENDER_DB_URL" -c "\dt+" | head -30 + psql "$RENDER_DB_URL" -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;" + ``` 4. Re-run the full smoke test checklist below. @@ -209,7 +214,7 @@ If the new deploy misbehaves before DNS cutover: - [x] Phase 2: Write `render.yaml` — project + prod environment, Postgres 17 basic-256mb, three Docker services, DATABASE_URL wiring, preDeploy for migrations + Telegram webhook - [x] Local Dockerfile smoke test — image builds, Octane boots, `/healthz` returns 200 with no `Set-Cookie` - [x] Phase 3: Provision Render Blueprint + first deploy — service live at `https://davidhartingdotcom-web.onrender.com` - - Fixes required post-provision: shell-wrap preDeployCommand (`bash scripts/predeploy.sh`), strip `cap_net_bind_service` from frankenphp binary (`setcap -r`), manually set `sync: false` secrets in dashboard + - Fixes required post-provision: shell-wrap preDeployCommand (`bash scripts/predeploy.sh`), strip `cap_net_bind_service` from frankenphp binary (`setcap -r`), manually set `sync: false` secrets in dashboard - [ ] Phase 4: Data migration + smoke tests - [ ] DNS cutover (follow-up) - [ ] Decommission Digital Ocean droplet diff --git a/tests/Unit/Http/Middleware/TrustProxiesTest.php b/tests/Unit/Http/Middleware/TrustProxiesTest.php index 316fdd6..4aec857 100644 --- a/tests/Unit/Http/Middleware/TrustProxiesTest.php +++ b/tests/Unit/Http/Middleware/TrustProxiesTest.php @@ -2,6 +2,7 @@ use App\Http\Middleware\TrustProxies; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; test('trusts X-Forwarded-Proto from any proxy', function () { $request = Request::create('http://app.onrender.com/test', 'GET'); @@ -12,7 +13,7 @@ (new TrustProxies)->handle($request, function (Request $req) use (&$seenSecure) { $seenSecure = $req->isSecure(); - return new Symfony\Component\HttpFoundation\Response('ok'); + return new Response('ok'); }); expect($seenSecure)->toBeTrue(); @@ -28,7 +29,7 @@ (new TrustProxies)->handle($request, function (Request $req) use (&$seenHost) { $seenHost = $req->getHost(); - return new Symfony\Component\HttpFoundation\Response('ok'); + return new Response('ok'); }); expect($seenHost)->toBe('davidharting.com'); From 0f14d94af4fd789fbe047583501a5af2d43685f5 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 12:17:19 -0400 Subject: [PATCH 28/33] docs: remove render-migration project doc Migration is complete: data migrated, DNS cut over, DO droplet powered off pending deletion. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/projects/render-migration.md | 220 ------------------------------ 1 file changed, 220 deletions(-) delete mode 100644 docs/projects/render-migration.md diff --git a/docs/projects/render-migration.md b/docs/projects/render-migration.md deleted file mode 100644 index b3ddd93..0000000 --- a/docs/projects/render-migration.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -name: render-migration -status: in-progress ---- - -# Render Migration - -Migrating davidharting.com from a Digital Ocean droplet running Docker Compose to Render.com, driven by `render.yaml` (Blueprint-as-code). - -## Why - -- Simplify infra (no more VM patching, no Docker Compose orchestration on a single host). -- Go all-in on a single PaaS now that I work at Render. -- Keep secrets management, scaling, and deploy UX inside one platform. - -## Target architecture - -``` -Internet - └─ Render ingress (terminates TLS, region: ohio) - └─ web service → FrankenPHP Octane (HTTP on $PORT) - └─ worker service → php artisan queue:work - └─ scheduler worker → php artisan schedule:work - └─ Postgres (managed) ← private network - └─ Cloudflare R2 (public + private buckets — unchanged) -``` - -Reliability posture: - -- Managed Postgres paid tier includes PITR + 7-day logical backups. -- Storage autoscaling (+50% / round to 5 GB) when the DB hits 90% full. -- Existing Spatie `backup:run --only-db` hourly job continues to push dumps to R2 private bucket. -- Three independent recovery paths for DB. - -## Decisions - -| Area | Choice | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Build source | `runtime: docker`, Render builds from repo `Dockerfile` each deploy | -| Region | `ohio` (closest to Indianapolis) | -| Postgres | `basic-256mb`, `postgresMajorVersion: "17"`, `diskSizeGB: 1`, storage autoscaling on — still a paid tier so PITR + 7-day logical backups are included | -| Scheduler | Two Render cron jobs: `backup:run --only-db` hourly, `backup:clean` daily (switched from always-on `schedule:work` worker to save cost; DB is small enough that cold-start + backup completes well under 60s) | -| Cache / session / queue | `database` driver, no Redis for v1 | -| Logging | `LOG_CHANNEL=stderr` only | -| Nightwatch | Dropped for v1, revisit later | -| DNS | Render-generated `*.onrender.com` URL for v1; Cloudflare custom domain is a follow-up | -| Env vars | One `envVarGroups` + per-service `DATABASE_URL` via `fromDatabase.connectionString` | -| Migrations | Web service `preDeployCommand` (Render orchestrates DB readiness, no `pg_isready` wait needed) | -| Telegram webhook | Also in web `preDeployCommand`; idempotent | -| CI gate | "After CI Checks Pass" auto-deploy in dashboard (post-provision); CI runs on `push: main` and `pull_request` | - -## Env var inventory - -Non-secret (in `envVarGroups.davidhartingdotcom-shared`): - -``` -APP_NAME, APP_ENV, APP_DEBUG, APP_URL, LOG_CHANNEL, LOG_LEVEL, -CACHE_DRIVER, SESSION_DRIVER, SESSION_LIFETIME, SESSION_SECURE_COOKIE, -QUEUE_CONNECTION, BROADCAST_DRIVER, DB_CONNECTION, -FILESYSTEM_DISK_PRIVATE, FILESYSTEM_DISK_PUBLIC, -R2_PUBLIC_BUCKET, R2_PUBLIC_URL, -MAIL_MAILER, MAIL_FROM_ADDRESS, MAIL_FROM_NAME, -OCTANE_SERVER, OCTANE_HTTPS -``` - -Secrets (`sync: false` — Render prompts once at blueprint creation): - -``` -APP_KEY ← paste the existing production key verbatim -MAILERSEND_API_KEY -MAILERSEND_ADMIN_ADDRESS -R2_ACCESS_KEY_ID -R2_SECRET_ACCESS_KEY -R2_ENDPOINT -R2_PRIVATE_BUCKET -TELEGRAM_TOKEN -TELEGRAM_DAVID_ID -ANTHROPIC_API_KEY -``` - -Per-service: - -``` -DATABASE_URL ← fromDatabase.connectionString (web, worker, scheduler) -``` - -**Critical**: `APP_KEY` MUST match the existing production key. A new random one invalidates all encrypted cookies, sessions, and any encrypted DB columns. - -## First-deploy steps - -1. Open the Render dashboard and create a new Blueprint pointing at this repo. -2. Render parses `render.yaml`, provisions the Postgres database, and prompts for all `sync: false` secrets. Paste values from the current DO droplet's `./secrets/*.txt` files. -3. First deploy sequence: - - DB provisions and becomes available. - - Web service builds (Dockerfile). - - Web `preDeployCommand` runs: `php artisan migrate --force && php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" && php artisan nutgram:register-commands`. Migrations apply to the empty DB; Telegram webhook is registered against the placeholder `APP_URL`. - - Octane starts, listens on `$PORT`. - - Worker and scheduler services build in parallel pipelines and come up when done. -4. In the dashboard, set **Auto-Deploy: After CI Checks Pass** on each of the three services. This makes Render wait on GitHub Actions' `CI` workflow. -5. Grab the generated hostname (e.g. `davidhartingdotcom-web.onrender.com`) and update `APP_URL` in the env group to `https://davidhartingdotcom-web.onrender.com`. Render redeploys; `preDeployCommand` re-registers the Telegram webhook at the real URL. - -## Data migration (during cutover window) - -### Before you open the maintenance window - -1. Run smoke tests 1–4 against the live Render URL with the empty database to confirm the app boots, serves HTML, and auth redirects work. This isolates any Render-specific issues from data issues. -2. Get the Render **external** Postgres connection string: Dashboard → `davidhartingdotcom-db` → Info → External Connection String. It looks like `postgresql://laravel:@dpg-xxx.ohio-postgres.render.com/laravel`. Save it as `$RENDER_DB_URL` in your shell. -3. Confirm you can reach the DO droplet via SSH. -4. Find the DB container name on the droplet: - ``` - ssh docker ps --format '{{.Names}}' | grep -i db - ``` - It will be something like `davidharting-com-db-1`. - -### Migration window - -The window is the gap between the final dump and flipping traffic to Render. For a low-traffic personal site a few minutes of read-only or brief downtime is fine — no formal maintenance page needed. - -1. **Take the dump on the DO droplet:** - - ```bash - ssh \ - "docker exec pg_dump -U laravel -Fc laravel" \ - > dump.dump - ``` - - This pipes the dump over SSH directly to your local machine — no intermediate `scp` step. - -2. **Restore to Render Postgres:** - - ```bash - pg_restore \ - --clean --if-exists \ - --no-owner --no-privileges \ - -d "$RENDER_DB_URL" \ - dump.dump - ``` - - Flag rationale: - - `--clean --if-exists` — drops objects before recreating; handles the schema that `preDeploy` already created - - `--no-owner` — skips `ALTER TABLE ... OWNER TO laravel`; Render's DB user is different - - `--no-privileges` — skips `GRANT/REVOKE` statements that reference the DO role - -3. **Verify row counts look right** (quick sanity check): - - ```bash - psql "$RENDER_DB_URL" -c "\dt+" | head -30 - psql "$RENDER_DB_URL" -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;" - ``` - -4. Re-run the full smoke test checklist below. - -## Local Dockerfile smoke test - -To validate the image locally before pushing: - -``` -docker build -t davidhartingdotcom:local . -docker run --rm -d --name render-test \ - -e PORT=9090 -p 9090:9090 \ - -e APP_KEY=base64:... -e APP_ENV=production -e APP_DEBUG=false \ - -e DB_CONNECTION=sqlite -e DB_DATABASE=:memory: \ - -e CACHE_DRIVER=array -e SESSION_DRIVER=array -e QUEUE_CONNECTION=sync \ - -e LOG_CHANNEL=stderr \ - davidhartingdotcom:local -curl -i http://localhost:9090/healthz # expect 200 OK, body "OK", no Set-Cookie -``` - -**Pick a known-free port** and check `lsof -iTCP: -sTCP:LISTEN` first. Common ports (8080, 8000) are often squatted by other dev tools (tilt, ssh tunnels, forwarded Docker Desktop mappings) that intercept traffic and can surface as unrelated-looking errors (e.g. spurious HTTPS redirects from whatever's actually listening). - -## Smoke tests - -After first deploy and after `APP_URL` update: - -1. `curl -i https://.onrender.com/healthz` → `200 OK`, **no `Set-Cookie` header** (proves /healthz is outside web middleware). -2. `curl -I https://.onrender.com/` → `200`, `Content-Type: text/html; charset=UTF-8`. -3. Browser to `/backend` → auth redirect; DevTools shows session cookie has `Secure` flag (proves TrustProxies + SESSION_SECURE_COOKIE). -4. Render web logs show structured stderr output and no "Log channel [stack]..." errors. -5. Telegram: send `/whoami` → bot responds. Confirms webhook re-registered and worker is processing nutgram. -6. `curl "https://api.telegram.org/bot$TELEGRAM_TOKEN/getWebhookInfo"` → URL matches `$APP_URL/api/telegram/webhook`. -7. From web shell: `php artisan backup:run --only-db` → completes, dump lands in R2 private bucket. -8. Postgres shell: `SELECT count(*) FROM sessions;` — grows with human traffic, not healthcheck polls. -9. Scheduler logs show `schedule:work` alive and ticking each minute; `backup:run` fires once per hour inside that. -10. Worker: `php artisan tinker` → dispatch a test job → worker log shows it processed. `failed_jobs` stays empty over 5 minutes. -11. Browse the site; confirm R2 CDN assets load from `cdn.davidharting.com`. - -## DNS cutover (follow-up, not v1) - -1. Add `davidharting.com` as a custom domain in Render web service. -2. In Cloudflare DNS, set the apex record as DNS-only (grey cloud) pointing at Render's CNAME target. Wait for Render to issue a cert. -3. After cert issued, optionally flip to proxied (orange cloud, Orange-to-Orange). -4. Update `APP_URL` in the env group to `https://davidharting.com`. Redeploy — preDeploy re-registers the Telegram webhook at the new URL. -5. Re-run smoke tests 1–6 against `https://davidharting.com`. - -## Rollback - -If the new deploy misbehaves before DNS cutover: - -- **Code rollback**: click "Rollback" on any service in the Render dashboard. Render re-promotes the previous deploy. -- **Full fallback**: the Digital Ocean droplet and its Postgres are still running during the overlap window. Reverting Cloudflare DNS to the droplet IP (if not yet flipped) returns traffic to the old stack. If DNS has been flipped, take down the Render service / redirect Cloudflare back to DO. -- **DB rollback** (after data migration): Render Postgres supports PITR. From the dashboard, trigger a point-in-time recovery to just before the cutover. - -## Deferred to v2 - -- Nightwatch re-introduction via Render log drains / metrics -- Custom domain + Cloudflare orange cloud (Orange-to-Orange) -- Consolidating worker + scheduler into fewer paid containers if cost matters -- Switching cache/session to Render Key Value (Redis) if DB contention shows up -- Decommissioning the DO droplet after a monitoring window (keep it running ~2 weeks as a safety net) - -## Status - -- [x] Phase 1: Code changes — TrustProxies, /healthz, Caddyfile, Dockerfile, CI, CLAUDE.md -- [x] Phase 2: Write `render.yaml` — project + prod environment, Postgres 17 basic-256mb, three Docker services, DATABASE_URL wiring, preDeploy for migrations + Telegram webhook -- [x] Local Dockerfile smoke test — image builds, Octane boots, `/healthz` returns 200 with no `Set-Cookie` -- [x] Phase 3: Provision Render Blueprint + first deploy — service live at `https://davidhartingdotcom-web.onrender.com` - - Fixes required post-provision: shell-wrap preDeployCommand (`bash scripts/predeploy.sh`), strip `cap_net_bind_service` from frankenphp binary (`setcap -r`), manually set `sync: false` secrets in dashboard -- [ ] Phase 4: Data migration + smoke tests -- [ ] DNS cutover (follow-up) -- [ ] Decommission Digital Ocean droplet From 1488884dff81257da7935082cfd47a237b67ba0f Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 12:36:11 -0400 Subject: [PATCH 29/33] test: condense health check coverage --- tests/Feature/Http/HealthzTest.php | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/tests/Feature/Http/HealthzTest.php b/tests/Feature/Http/HealthzTest.php index 95a030b..3971c71 100644 --- a/tests/Feature/Http/HealthzTest.php +++ b/tests/Feature/Http/HealthzTest.php @@ -3,28 +3,15 @@ use Tests\TestCase; describe('GET /healthz', function () { - test('returns 200 OK', function () { + test('returns a lightweight health check response without touching session state', function () { /** @var TestCase $this */ - $response = $this->get('/healthz'); - - $response->assertOk(); - $response->assertSeeText('OK'); - }); + $this->assertDatabaseCount('sessions', 0); - test('does not set any session cookie', function () { - /** @var TestCase $this */ $response = $this->get('/healthz'); $response->assertOk(); + $response->assertSeeText('OK'); expect($response->headers->get('Set-Cookie'))->toBeNull(); - }); - - test('does not write a session row', function () { - /** @var TestCase $this */ - $this->assertDatabaseCount('sessions', 0); - - $this->get('/healthz')->assertOk(); - $this->assertDatabaseCount('sessions', 0); }); }); From 71ab035172eab321d59a16828e79fc09630c8fe5 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 12:36:18 -0400 Subject: [PATCH 30/33] fix: harden trusted proxy headers on Render --- app/Http/Middleware/TrustProxies.php | 11 +++---- .../Unit/Http/Middleware/TrustProxiesTest.php | 31 +++++++------------ 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 2b1825f..9d15166 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -10,9 +10,10 @@ class TrustProxies extends Middleware /** * The trusted proxies for this application. * - * Trusts any proxy because the app runs on a PaaS where the container has no - * direct internet path — the only inbound route is through the platform's - * ingress, so there is no header-spoofing surface to narrow. + * Render forwards public traffic through its ingress before it reaches the + * container port, so trusting the ingress path is expected on this PaaS. + * Do not trust X-Forwarded-Host; Render should preserve the real Host + * header, and accepting a forwarded host is unnecessary spoofing surface. * * @var array|string|null */ @@ -25,8 +26,6 @@ class TrustProxies extends Middleware */ protected $headers = Request::HEADER_X_FORWARDED_FOR | - Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | - Request::HEADER_X_FORWARDED_PROTO | - Request::HEADER_X_FORWARDED_AWS_ELB; + Request::HEADER_X_FORWARDED_PROTO; } diff --git a/tests/Unit/Http/Middleware/TrustProxiesTest.php b/tests/Unit/Http/Middleware/TrustProxiesTest.php index 4aec857..ddb17e3 100644 --- a/tests/Unit/Http/Middleware/TrustProxiesTest.php +++ b/tests/Unit/Http/Middleware/TrustProxiesTest.php @@ -4,33 +4,26 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -test('trusts X-Forwarded-Proto from any proxy', function () { - $request = Request::create('http://app.onrender.com/test', 'GET'); +test('trusts Render forwarded scheme headers without trusting forwarded host', function () { + $request = Request::create('http://davidharting.com/test', 'GET'); $request->headers->set('X-Forwarded-Proto', 'https'); + $request->headers->set('X-Forwarded-Port', '443'); + $request->headers->set('X-Forwarded-Host', 'attacker.example'); $request->server->set('REMOTE_ADDR', '10.0.0.1'); $seenSecure = null; - (new TrustProxies)->handle($request, function (Request $req) use (&$seenSecure) { - $seenSecure = $req->isSecure(); - - return new Response('ok'); - }); - - expect($seenSecure)->toBeTrue(); -}); - -test('trusts X-Forwarded-Host from any proxy', function () { - $request = Request::create('http://internal/test', 'GET'); - $request->headers->set('X-Forwarded-Host', 'davidharting.com'); - $request->headers->set('X-Forwarded-Proto', 'https'); - $request->server->set('REMOTE_ADDR', '10.0.0.1'); - + $seenPort = null; $seenHost = null; - (new TrustProxies)->handle($request, function (Request $req) use (&$seenHost) { + + (new TrustProxies)->handle($request, function (Request $req) use (&$seenSecure, &$seenPort, &$seenHost) { + $seenSecure = $req->isSecure(); + $seenPort = $req->getPort(); $seenHost = $req->getHost(); return new Response('ok'); }); - expect($seenHost)->toBe('davidharting.com'); + expect($seenSecure)->toBeTrue() + ->and($seenPort)->toBe(443) + ->and($seenHost)->toBe('davidharting.com'); }); From 3debb1163ee6a2bf6409817ced240e51e7b4e2d8 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 12:36:22 -0400 Subject: [PATCH 31/33] docs: simplify Render migration notes --- CLAUDE.md | 32 ++++++++++++++++++-------------- render.yaml | 34 ++++++---------------------------- 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15060a3..882c4f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,25 +8,29 @@ Quick check: if `php artisan test` fails with autoload errors, run the setup. ## Architecture overview -The site runs on Render.com, provisioned from `render.yaml` (Blueprint-as-code). +The site runs on Render.com from `render.yaml`. ``` Internet - └─ Render ingress (terminates TLS, region: ohio) - └─ web service → FrankenPHP Octane (HTTP on $PORT) - └─ worker service → php artisan queue:work - └─ scheduler worker → php artisan schedule:work - └─ Postgres (managed) ← private network - └─ Cloudflare R2 (public + private buckets) + -> Render ingress + -> web service: FrankenPHP Octane on $PORT + -> Render Postgres on the private network ``` -- All services build from the repo `Dockerfile` (`runtime: docker`). Render does not share builds across services, so each of the three services builds the image separately; Dockerfile layer ordering is tuned so dependency layers stay cached. -- Render terminates TLS at the ingress — the container listens on plain HTTP at `$PORT`. The `Caddyfile` binds `:{$PORT:80}`. -- Shared env vars live in an `envVarGroups` block; secrets use `sync: false` (prompted once at blueprint creation, then managed via the Render dashboard). -- Database wiring is a single `DATABASE_URL` sourced from the managed Postgres via `fromDatabase.connectionString`. -- Migrations + Telegram webhook registration run in the web service `preDeployCommand`. If preDeploy fails, Render keeps the prior version live (zero-downtime). -- Cloudflare DNS is a follow-up — for now the site serves on a generated `*.onrender.com` URL. -- Historical note: was previously Cloudflare (orange-cloud) → Digital Ocean droplet running Docker Compose. See `docs/projects/render-migration.md` for migration history and follow-ups. +Supporting services: + +- `davidhartingdotcom-worker`: runs `php artisan queue:work`. +- `davidhartingdotcom-backup-run`: Render cron job that runs database backups. +- `davidhartingdotcom-backup-clean`: Render cron job that prunes old backups. +- Cloudflare DNS points `davidharting.com` at Render. Cloudflare R2 stores public and private objects. + +Operational notes: + +- Render terminates TLS; the container listens on plain HTTP at `$PORT`. +- `render.yaml` owns service, database, worker, and cron definitions. +- Secrets are managed in Render, not committed to git. Prefer an IaC-friendly path, such as Render secret files, over long-term dashboard-only configuration. +- The web service `preDeployCommand` runs migrations and Telegram webhook registration before new web instances receive traffic. +- Historical note: this previously ran on a Digital Ocean droplet with Docker Compose. See `docs/projects/render-migration.md` for migration history. ## Commands diff --git a/render.yaml b/render.yaml index a39b9c6..a3974e0 100644 --- a/render.yaml +++ b/render.yaml @@ -22,8 +22,7 @@ projects: value: production - key: APP_DEBUG value: "false" - # Replace with the real generated onrender.com URL after first provision, - # then again with https://davidharting.com after DNS cutover. + # Canonical production URL; keep this aligned with the custom domain below. - key: APP_URL value: https://davidharting.com @@ -69,32 +68,9 @@ projects: - key: OCTANE_HTTPS value: "false" - # ---- Secrets (sync: false — Render prompts once at blueprint creation) ---- - # After initial creation these are managed via the dashboard; blueprint - # updates IGNORE sync: false entries and will not re-prompt. - # Paste APP_KEY verbatim from the existing production .secrets/APP_KEY.txt — - # a new random value invalidates all encrypted cookies, sessions, and - # encrypted DB columns. - - key: APP_KEY - sync: false - - key: MAILERSEND_API_KEY - sync: false - - key: MAILERSEND_ADMIN_ADDRESS - sync: false - - key: R2_ACCESS_KEY_ID - sync: false - - key: R2_SECRET_ACCESS_KEY - sync: false - - key: R2_ENDPOINT - sync: false - - key: R2_PRIVATE_BUCKET - sync: false - - key: TELEGRAM_TOKEN - sync: false - - key: TELEGRAM_DAVID_ID - sync: false - - key: ANTHROPIC_API_KEY - sync: false + # Secrets are not listed here because Render ignores sync: false + # inside Blueprint-managed env groups. Follow-up: move secrets to + # Render secret files so they are not long-term dashboard-only config. services: - name: davidhartingdotcom-web @@ -147,6 +123,7 @@ projects: plan: starter region: ohio dockerfilePath: ./Dockerfile + # Run a database-only backup at the top of every hour. schedule: "0 * * * *" dockerCommand: php artisan backup:run --only-db envVars: @@ -162,6 +139,7 @@ projects: plan: starter region: ohio dockerfilePath: ./Dockerfile + # Prune old backups once per day at midnight UTC. schedule: "0 0 * * *" dockerCommand: php artisan backup:clean envVars: From e64fd8f9c648b0c8ee76f340d2b500d667e8a970 Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 12:43:00 -0400 Subject: [PATCH 32/33] chore: gate Render autodeploys on CI --- render.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/render.yaml b/render.yaml index a3974e0..0bb4896 100644 --- a/render.yaml +++ b/render.yaml @@ -79,6 +79,7 @@ projects: plan: starter region: ohio dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass healthCheckPath: /healthz domains: - davidharting.com @@ -106,6 +107,7 @@ projects: plan: starter region: ohio dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass # --tries=3 --backoff=30 makes the worker resilient to the ~60s window # where it might boot new code against the old DB schema (preDeploy runs # only on the web service; worker has its own independent pipeline). @@ -123,6 +125,7 @@ projects: plan: starter region: ohio dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass # Run a database-only backup at the top of every hour. schedule: "0 * * * *" dockerCommand: php artisan backup:run --only-db @@ -139,6 +142,7 @@ projects: plan: starter region: ohio dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass # Prune old backups once per day at midnight UTC. schedule: "0 0 * * *" dockerCommand: php artisan backup:clean From 0af79ebe5008eabebbfe15dc3f1d91b834278eda Mon Sep 17 00:00:00 2001 From: David Harting Date: Fri, 8 May 2026 14:24:49 -0400 Subject: [PATCH 33/33] chore: load Render secret file at runtime --- render.yaml | 15 +++++++-------- scripts/render-with-secrets.sh | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) create mode 100755 scripts/render-with-secrets.sh diff --git a/render.yaml b/render.yaml index 0bb4896..5c4e5c3 100644 --- a/render.yaml +++ b/render.yaml @@ -68,9 +68,8 @@ projects: - key: OCTANE_HTTPS value: "false" - # Secrets are not listed here because Render ignores sync: false - # inside Blueprint-managed env groups. Follow-up: move secrets to - # Render secret files so they are not long-term dashboard-only config. + # Secrets are mounted as /etc/secrets/secrets.env and linked to + # /app/.env by scripts/render-with-secrets.sh at runtime. services: - name: davidhartingdotcom-web @@ -88,12 +87,12 @@ projects: # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, # encode, and log redaction. $PORT is substituted inside the Caddyfile via # Caddy's native env syntax (:{$PORT:80}). - dockerCommand: php artisan octane:frankenphp --caddyfile=Caddyfile --host=0.0.0.0 --port=$PORT + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan octane:frankenphp --caddyfile=Caddyfile --host=0.0.0.0 --port=$PORT # Migrations run once per deploy before the new version takes traffic. If # any command fails, Render cancels the deploy with zero downtime. # Telegram webhook registration is idempotent (setWebhook / setMyCommands # are upserts on Telegram's side). - preDeployCommand: bash /app/scripts/predeploy.sh + preDeployCommand: bash /app/scripts/render-with-secrets.sh bash /app/scripts/predeploy.sh envVars: - fromGroup: davidhartingdotcom-shared - key: DATABASE_URL @@ -111,7 +110,7 @@ projects: # --tries=3 --backoff=30 makes the worker resilient to the ~60s window # where it might boot new code against the old DB schema (preDeploy runs # only on the web service; worker has its own independent pipeline). - dockerCommand: php artisan queue:work -v --tries=3 --backoff=30 + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan queue:work -v --tries=3 --backoff=30 envVars: - fromGroup: davidhartingdotcom-shared - key: DATABASE_URL @@ -128,7 +127,7 @@ projects: autoDeployTrigger: checksPass # Run a database-only backup at the top of every hour. schedule: "0 * * * *" - dockerCommand: php artisan backup:run --only-db + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan backup:run --only-db envVars: - fromGroup: davidhartingdotcom-shared - key: DATABASE_URL @@ -145,7 +144,7 @@ projects: autoDeployTrigger: checksPass # Prune old backups once per day at midnight UTC. schedule: "0 0 * * *" - dockerCommand: php artisan backup:clean + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan backup:clean envVars: - fromGroup: davidhartingdotcom-shared - key: DATABASE_URL diff --git a/scripts/render-with-secrets.sh b/scripts/render-with-secrets.sh new file mode 100755 index 0000000..8ba2cd9 --- /dev/null +++ b/scripts/render-with-secrets.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +secrets_file="${RENDER_SECRETS_ENV_FILE:-/etc/secrets/secrets.env}" +laravel_env_file="${LARAVEL_ENV_FILE:-/app/.env}" + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [args...]" >&2 + exit 64 +fi + +if [[ ! -f "$secrets_file" ]]; then + echo "Missing Render secrets file: $secrets_file" >&2 + exit 66 +fi + +ln -sf "$secrets_file" "$laravel_env_file" + +exec "$@"