Skip to content
bflo.sh

The runbook — from clone to running system

Bringing the stack up, publishing articles by saving files, and the troubleshooting paths that actually get used.

Adapted from the repo's docs/runbook.md — Part of Internals: the platform's design set, published as content on the platform it describes.

Operational runbook for bringing the stack up locally and driving it day to day. The source of truth for the topology is ../infra/README.md; this document is the short path from a clone to a running, verifiable system. When the two disagree, infra/README.md wins.

Everything runs inside DDEV — the host needs only Docker + DDEV, no local PHP or Node.js. If a task can't be driven through ddev, that's a gap to close in infra/, not a dependency to install on your machine.


TL;DR (already bootstrapped)

ddev start                 # brings up web (Laravel), db (Postgres 16), redis, frontend (Next.js)
ddev describe              # URLs + service health

Stop with ddev stop; full reset in Reset.


Prerequisites (host, one-time)

  • DDEV ≥ 1.23.5 and a Docker provider.
  • Claude Code with Agent Teams enabled (preconfigured in .claude/settings.json).

No host Node.js or PHP is required or assumed.


First-time setup

A. Existing project, fresh clone

ddev start                      # creates containers; provisions Postgres + Redis
ddev composer install           # backend PHP deps (in the web container)
ddev frontend npm install       # frontend deps (in the Node 22 container)
ddev artisan migrate            # apply the schema
# optional: ddev artisan db:seed
ddev contract-lint              # confirm the contract is green

Then run the smoke test.

B. Brand-new project from the template

Only when spinning a new repo from this template (see ../README.md):

cp -r <this-template> my-project && cd my-project && git init
grep -rl '<PROJECT_NAME>' . --exclude-dir=.git | xargs sed -i 's/<PROJECT_NAME>/my-project/g'
infra/ddev/bootstrap.sh my-project     # generates .ddev/ (config, add-ons, services, commands)
ddev start
# install the Laravel + Next.js skeletons per infra/README.md, then:
ddev contract-lint

What comes up

ddev start brings up the whole system (topology detail in ../infra/README.md). It is a host-split topology — two hostnames on the shared 80/443 router resolve to two containers, https-only with an automatic http→https redirect:

Service Runtime Reached at
web PHP 8.5 (nginx-fpm), Composer, Node 22 https://api.publishing-platform.ddev.site (API under /v1)
frontend Node 22 running npm run dev https://publishing-platform.ddev.site (also :3000/:3001)
db PostgreSQL 16 ddev psql (db:5432 inside)
redis Redis redis:6379 (cache, queues, sessions)

A Laravel scheduler daemon (schedule:work) also runs inside web and fires the ~1-minute reindex tick.


Smoke test

# API liveness (expect HTTP 200, application/problem+json only on failure)
curl -sS https://api.publishing-platform.ddev.site/v1/health | head

# Public read endpoints (contract/openapi.yaml, currently v0.8.1) — all under /v1:
#   GET /health  /site  /articles  /articles/{path}  /sections  /sections/{path}
#   GET /tags  /tags/{tag}  /authors  /authors/{author}  /search?q=...  /assets/{path}
curl -sS 'https://api.publishing-platform.ddev.site/v1/search?q=hello' | head

# Frontend renders:
curl -sSI https://publishing-platform.ddev.site | head -1     # expect 200

The read boundary is unauthenticated and read-only (ADR-0006); paths/verbs/payloads conform to contract/openapi.yaml.


Everyday commands

Task Command
Start / restart / stop ddev start · ddev restart · ddev stop
Backend (Composer/Artisan) ddev composer <args> · ddev artisan <args>
Run a migration ddev artisan migrate
Frontend ddev frontend npm <args> (e.g. npm run dev is already the service)
Postgres shell ddev psql
Redis shell ddev redis-cli
Lint the contract ddev contract-lint
Tail logs ddev logs -s web · ddev logs -s frontend

Publishing & updating articles

The filesystem is the CMS (ADR-0005): content lives in backend/storage/app/content/ — each directory is a section (companion metadata.json), each *.md file an article, path = URL. What you actually have to do after writing or editing:

1. Write the file — front-matter reference

---
title: "…"                        # becomes the page's ONLY h1
description: "…"                  # lists, meta tags, feed item summary (optional;
                                  #   the feed also carries the full rendered body)
author: "Bogdan Florian"          # free label; slug derived server-side
published_at: 2026-07-06T22:00:00Z  # UTC gate — future instant = hidden until then
updated_at: 2026-07-06T22:00:00Z    # "Updated" shows only when a DIFFERENT calendar day
tags: [x, y]
draft: false                      # true = invisible everywhere
unlisted: false                   # true = reachable by URL + site.nav, but excluded from
---                               #   listings/tags/authors/search/feed/prev-next (ADR-0015)
  • Scheduling is automatic: a future published_at (or scheduled_at) keeps the article hidden until that UTC instant — no action needed at go-live.
  • The body may start with its own # Heading — the renderer demotes body h1→h2.
  • Images: put files in an assets/ subdir next to the article and reference them relatively (cover: "assets/x.png", !…). Served via /v1/assets; allowlist png/jpg/jpeg/gif/webp/avif (no SVG, no .md/.json).
  • ```mermaid fences render client-side (ADR-0012) — just write them.

2. Indexing — nothing to do (usually)

The scheduler reindexes every minute; body edits re-render automatically (the render cache keys on content hash + renderer version). To force it immediately:

ddev artisan content:reindex

3. Seeing it in the browser — nothing to do (Track 4d)

Save the file and reload: the page is fresh within ~90 s, no restarts. The loop (ADR-0016): reindex tick (≤ 1 min) → content.* events → content:relay-events → signed webhook POST /api/revalidate on the frontend → revalidateTag → the next request re-renders. This covers articles, sections, tags, authors, search, the feed, the sitemap, AND the site-identity block — typically < 60 s end to end. To skip the tick wait:

ddev artisan content:reindex   # reindex + relay now; fresh on the next reload

Both tiers need the same REVALIDATE_SECRET set (frontend/.env.local + backend/.env) — with it missing or mismatched the webhook is rejected (401/503) and freshness falls back to the ISR safety-net windows: ≤ 5 min for pages, ≤ 1 h for feed.xml/sitemap.xml.

Troubleshooting fallback (rarely needed now)

If a change refuses to appear (webhook misconfigured, relay down), the old manual bust still works:

docker restart ddev-publishing-platform-frontend   # boots warm in seconds

If styling/assets still look stale after a restart (wedged Turbopack cache — happens if the container previously died mid-compile):

docker exec ddev-publishing-platform-frontend rm -rf /var/www/html/frontend/.next
docker restart ddev-publishing-platform-frontend

Site identity & nav

Masthead name, home thesis/tagline, footer memo/copyright, and the top navigation are all the site block of the root backend/storage/app/content/metadata.json (ADR-0014/15). The API serves an edit on the next request (no reindex needed); the frontend follows via the same revalidation webhook (the site cache tag) — worst case the ISR safety-net windows above.

Quick sanity check

curl -s https://api.publishing-platform.ddev.site/v1/articles/<path> | head

Full payload = indexed and published. A problem+json 404 = still gated (future date, draft) or not yet reindexed. An unlisted page is absent from /v1/articles but 200 by path — that's the feature, not a bug.


Verify (Definition of Done)

Run the owning tier's verify before calling work done (also in CLAUDE.md):

Scope Command Covers
Contract ddev contract-lint Spectral ruleset (in-container)
Backend ddev composer verify Pint check + PHPStan + artisan test
Frontend ddev frontend npm run verify lint + typecheck + tests + build
Platform ddev restart boots green; services respond topology

Host-split routing gotcha

The DDEV-generated Traefik dynamic config is taken over by the project — the live file .ddev/traefik/config/publishing-platform.yaml is git-ignored and DDEV will not regenerate routing when hostnames/ports change. The editable source of truth is ../infra/ddev/traefik/publishing-platform.yaml; a post-start hook re-installs it on every start.

Change routing by editing the infra/ copy, then ddev restart — never hand-edit the git-ignored live copy.

If HMR breaks, confirm the frontend keeps the bare-host origin in next.config allowedDevOrigins and its API base URL points at the api. host.


LAN access (optional)

To reach the stack from other machines on the trusted internal network, two binds are required (bind_all_interfaces in-repo + router_bind_all_interfaces global, then recreate the router) and external machines must map both hostnames to this host's LAN IP. Full procedure — including the one-time global step and the docker rm -f ddev-router && ddev start router re-bind — is in ../infra/README.md. LAN devices must also trust the mkcert CA to accept the certificate.


Reset / teardown

ddev stop                  # stop containers (data preserved)
ddev restart               # recreate + re-run post-start hooks (Traefik re-install)
ddev delete -O             # DESTRUCTIVE: remove project + database volume (keeps files)

After a fresh ddev delete/clone, re-run First-time setup.


Troubleshooting

Symptom Check
ddev start fails on frontend port 3000 Don't add a raw ports: mapping to the frontend service — the router already binds :3000 (infra/README.md).
API 404s on a path you expect Route ordering in backend/routes/api.php is load-bearing; the path must exist in contract/openapi.yaml first.
Contract lint fails ddev contract-lint prints the Spectral rule + location; the contract is the source of truth, fix there and let consumers follow.
HMR / websocket dead Traefik forwards Connection: Upgrade; verify allowedDevOrigins and that you edited the infra/ Traefik source, then ddev restart.
Ports show 127.0.0.1 when you want LAN Trust ss -ltn, not ddev describe's cosmetic display; see LAN section.