A self-hostable, AI-agent-friendly blog platform built on Next.js 16, PostgreSQL, and BetterAuth. Designed as a first-class publishing surface for both humans and machine agents — markdown authoring and a polished dashboard for people, plus a bearer-token REST API, an RSS feed, and a sitemap for software.
- For humans: markdown editor, configurable publication design, dashboard, role-based access, server-rendered public pages, and a searchable story archive.
- For agents / LLM tools: a token-authenticated JSON API for creating, listing, updating, and deleting posts; a
/api/render-markdownpreview endpoint; a public sitemap and RSS feed; and first-class TypeScript-friendly error responses. - For self-hosters: a single
docker compose up -d --buildbrings up the whole stack. Data stays in your Postgres volume. No external service required.
- Quick start (Docker)
- Quick start (local development)
- API quick start for agents
- Architecture
- Configuration
- Common commands
- Data model
- Discovery: sitemap, RSS, Open Graph
- Markdown rendering and post format
- Theming
- Security and authentication
- Operational scripts
- Deployment
- Troubleshooting
- Project layout
- Contributing and licensing
The deep reference lives in docs/api.md — full request/response shapes, error formats, headers, and role semantics for every route. The product spec is in REQUIREMENTS.md and the system design in DESIGN.md.
Requirements: Docker 23+ (for BuildKit cache mounts) and Docker Compose v2.
OpenBlog ships a pre-built Docker image to GitHub Container Registry. You don't need to clone the source repo to run it — just download the compose file (or use scripts/install.sh) and point Docker at it.
curl -fsSL https://raw.githubusercontent.com/IamCoder18/OpenBlog/main/scripts/install.sh | bashThe installer:
- Downloads
docker-compose.prod.yamlinto the current directory. - Prompts for
BASE_URL,BLOG_NAME, and admin credentials. - Pulls the published image from
ghcr.io/iamcoder18/openblog:latest. - Brings up Postgres + the app, waits for healthy, runs migrations.
- Bootstraps your admin user and prints the access URL.
Non-interactive form (CI, scripted installs):
curl -fsSL https://raw.githubusercontent.com/IamCoder18/OpenBlog/main/scripts/install.sh | \
bash -s -- --non-interactive \
--base-url "https://blog.example.com" \
--admin-email "you@example.com" \
--admin-password "$(openssl rand -base64 24)" \
--image "ghcr.io/iamcoder18/openblog:v0.1.0"PowerShell equivalent on native Windows:
irm https://raw.githubusercontent.com/IamCoder18/OpenBlog/main/scripts/install.ps1 | iexIf you'd rather see what you're running:
git clone https://github.com/IamCoder18/OpenBlog.git openblog && cd openblog
# 1. Set the three required deployment values.
cat > .env <<EOF
AUTH_SECRET="$(openssl rand -base64 32)"
POSTGRES_PASSWORD="$(openssl rand -hex 32)"
BASE_URL="http://localhost:3000"
EOF
# 2. Pull the published image and start the stack
docker compose up -d
# 3. Bootstrap an admin user inside the app container
docker exec -it openblog-app \
./node_modules/.bin/tsx scripts/create-admin.ts you@example.com "Your Name" "S3cureP@ss!"
# 4. Open the app
# http://localhost:3000That's it. Postgres runs in openblog-db, the app in openblog-app, and a named volume openblog_postgres_data persists the database between restarts.
To stop and remove everything (data volume included):
docker compose down -vIf you're hacking on the codebase (or want to customize NEXT_PUBLIC_* values that are baked in at build time), use docker-compose.local.yaml:
docker compose -f docker-compose.local.yaml up -d --buildThis builds the image from your local Dockerfile instead of pulling from the registry. See CONTRIBUTING.md for the full dev workflow.
Does
docker composefollow.env? Yes. Compose loads.envfrom the project root automatically and substitutes the variables inbuild.args,environment, and${VAR}references. After the stack is up, change.envthendocker compose up -d --force-recreate appfor new values to take effect.
For local dev against a Postgres-only container (hot reload, debugger, full source access):
# In the project root
docker compose -f docker-compose.test.yaml up -d
pnpm install
pnpm prisma migrate dev # creates the test DB schema
pnpm dev # http://localhost:4000See CONTRIBUTING.md for the full dev workflow, code conventions, and testing layers.
The intended machine-client flow is to sign in to an author account, mint an API key, store it securely, then use Authorization: Bearer ob_<base64url> for subsequent requests. Session cookies are fine for browsers; scoped bearer tokens are intended for non-browser tools.
curl -X POST http://localhost:3000/api/auth/sign-up/email \
-H "Content-Type: application/json" \
-d '{
"email": "agent@example.com",
"password": "S3cureP@ss!",
"name": "My Agent"
}'
# Response sets a better-auth.session_token cookie.
# But agents usually skip this — see "Bootstrap via admin" below.For unattended agents the recommended bootstrap is: create an account from a human browser using the login/signup flow once, then mint an API key from the dashboard. If you can't do that, see Bootstrap via admin below.
Once signed in via /auth/login, mint a long-lived API key:
curl -X POST http://localhost:3000/api/keys \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name": "production-agent", "scopes": ["posts:read", "posts:write"]}'Response:
{
"id": "cmr…",
"name": "production-agent",
"key": "ob_<43 base64url chars>",
"createdAt": "2026-…Z",
"expiresAt": null
}The key field is shown only once. Store it immediately. List keys later with GET /api/keys (the response does not include the key material — only metadata).
Omit expiresInDays for a key that remains valid until revoked, or provide a
value from 1 through 365 for a time-limited key.
curl -X POST http://localhost:3000/api/posts \
-H "Authorization: Bearer ob_<your-key>" \
-H "Content-Type: application/json" \
-d '{
"title": "Hello, world",
"slug": "hello-world",
"bodyMarkdown": "# Hello\n\nThis is **markdown**.",
"visibility": "PUBLIC",
"seoDescription": "A first post.",
"tags": ["intro", "meta"]
}'POST, PUT, and DELETE post endpoints accept a bearer key with the required scope. Post mutations also require the key owner to have the AUTHOR or ADMIN role, and non-admin authors may mutate only their own posts.
curl "http://localhost:3000/api/posts?limit=10&visibility=PUBLIC" \
-H "Authorization: Bearer ob_<your-key>"Collections include public posts plus the authenticated author's own non-public posts; admins can deliberately query all posts. Unlisted posts remain direct-link-only. An arbitrary authorId never grants access to another author's non-public content.
curl -X POST http://localhost:3000/api/render-markdown \
-H "Authorization: Bearer ob_<your-key>" \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello\n\n**bold** *italic*"}'
# → { "html": "<h1>Hello</h1>\n<p><strong>bold</strong> <em>italic</em></p>" }If your deployment has SIGN_UP_ENABLED=false, create an agent account with a script:
docker exec -it openblog-app \
./node_modules/.bin/tsx scripts/create-admin.ts agent@example.com "My Agent" "S3cureP@ss!"By default this creates the user with the ADMIN role (the script's name is historical — it both creates and promotes). For non-admin agents, sign in once via the browser, mint a key, and you're set.
For the full reference (every header, every error format, every auth nuance) see docs/api.md.
┌─────────────────────────────────────┐
│ Next.js 16 (app) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Server │ │ Client │ │
│ │ - API │ │ - Pages │ │
│ │ - SSR │ │ - Editor │ │
│ │ - Prisma │ │ - Themes │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ┌────▼──────┐ │
│ │ Prisma │ │
│ └────┬──────┘ │
└─────────┼──────────────────────────────┘
│
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
- Next.js 16 with the App Router, running in
output: "standalone"mode for the Docker image. - Prisma 7 for database access, with
@prisma/adapter-pgand a custom generator output undersrc/lib/prisma/. - BetterAuth for sessions and credential auth, with a Postgres-backed credential provider.
- PostgreSQL 16 in a separate container.
- pnpm 9 with BuildKit-cached
node_modulesin the Docker build.
The Dockerfile is three-stage: fetcher (cached pnpm install) → builder (Prisma generate + next build, cached .next/cache) → runner (minimal standalone image with pnpm prune --prod, no devDeps).
All configuration is via environment variables. Compose reads .env from the project root automatically. Only three vars are strictly required:
| Variable | Why |
|---|---|
AUTH_SECRET |
BetterAuth signing key. Generate with openssl rand -base64 32. Never reuse between environments. Injected at container start — never baked into the Docker image. |
POSTGRES_PASSWORD |
Deployment-specific database credential. Generate a URL-safe value with openssl rand -hex 32; the production Compose file has no default password. |
BASE_URL |
Public-facing URL the app is served from. Used by BetterAuth's trustedOrigins and the sitemap. Set to the URL you actually reach the app from. |
Full list of vars (with defaults) in .env.example.
Next.js inlines NEXT_PUBLIC_* environment variables into the client JavaScript bundle at build time — they become literal string constants in the browser code. Unprefixed vars are only readable on the server (route handlers, server components, getServerSideProps-style contexts).
| Form | Where it's read |
|---|---|
BASE_URL |
Server: BetterAuth trustedOrigins, sitemap generation, RSS feed URLs |
NEXT_PUBLIC_BASE_URL |
Client: client-side fetch helpers (e.g. /explore, /blog/[slug]), OpenGraph fallbacks |
BLOG_NAME |
Server: RSS <title>, sitemap metadata |
NEXT_PUBLIC_BLOG_NAME |
Client: <title>, nav, footer, OpenGraph tags |
The compose file passes both forms automatically, so you only ever set BASE_URL and BLOG_NAME. After a build, changing either form requires a new image because NEXT_PUBLIC_* is baked into the static bundle. To change BASE_URL / BLOG_NAME without rebuilding, patch src/lib/config.ts to fall back to the unprefixed var at runtime (this is already done in config.ts as of the latest version).
| Variable | Default | Purpose |
|---|---|---|
BLOG_NAME |
OpenBlog |
Display name in titles, nav, footer, RSS. |
NEXT_PUBLIC_BLOG_NAME |
OpenBlog |
Same, inlined for the client. |
SIGN_UP_ENABLED |
false |
When true, /auth/signup is open to the public. Off by default — create users via the admin script. |
DISABLE_RATE_LIMITING |
false |
Disables BetterAuth rate limiting. Used by E2E tests; do not enable in production. |
DATABASE_URL |
compose default | Postgres connection string. Hardcoded in docker-compose.prod.yaml; per-deployment values are baked in by the installer. |
PORT |
3000 |
Port the Next.js standalone server listens on inside the container. |
NODE_ENV |
production |
Set to development only when running pnpm dev outside Docker. |
| Command | What it does |
|---|---|
docker compose up -d --build |
Build the image and start the stack |
docker compose logs -f app |
Tail app logs |
docker compose down -v |
Tear down stack + delete Postgres volume |
pnpm dev |
Local dev server (port 4000) |
pnpm build |
Production build (standalone) |
pnpm start |
Serve the production build |
pnpm check |
Lint + format check + TypeScript typecheck |
pnpm lint:fix |
Auto-fix lint errors |
pnpm format:fix |
Format with oxfmt |
pnpm test:unit |
Vitest unit tests |
pnpm test:full |
Unit + integration + E2E (orchestrated) |
pnpm prisma migrate dev |
Create + apply a new migration (dev) |
pnpm prisma migrate deploy |
Apply pending migrations (prod / entrypoint) |
docker exec openblog-app npx prisma migrate deploy |
Same, from inside the running container |
pnpm run promote-admin -- <email> |
Promote an existing user to ADMIN |
docker exec openblog-app ./node_modules/.bin/tsx scripts/create-admin.ts … |
Bootstrap a user (creates with ADMIN role by default) |
docker exec openblog-app ./node_modules/.bin/tsx scripts/change-password.ts … |
Reset a user's password |
The full schema is in prisma/schema.prisma. Summary:
| Model | Purpose | Notable fields |
|---|---|---|
User |
Account identity | email (unique), emailVerified, name |
UserProfile |
1:1 with User, holds the role | role: ADMIN | AUTHOR | AGENT | GUEST |
Account |
Provider credentials (BetterAuth) | providerId, password, accessToken, etc. |
Session |
BetterAuth session | token, expiresAt, ipAddress, userAgent |
Verification |
Email-verification tokens | |
ApiKey |
Scoped bearer-token digests | digest, safe prefix, scopes, optional expiry, last use, revocation |
Post |
A blog post | slug, bodyMarkdown, visibility, publishedAt, isPinned, isFeatured |
PostMetadata |
1:1 with Post | seoDescription, coverImage, tags: String[] |
SiteSettings |
Key/value app config (theme, fuzzy-search threshold, etc.) | key, value |
PageView |
Hash-IP page-view log for analytics | path, referrer, userAgent, ipHash, postId |
Visibility on Post:
PUBLIC— indexed, listed in feeds/sitemaps, visible to anonymous readers.UNLISTED— served at its URL but not listed anywhere public.PRIVATE— only visible to its author and admins.DRAFT— only visible to its author.
OpenBlog publishes machine-readable discovery surfaces that work without authentication:
| URL | Format | Contents |
|---|---|---|
GET /sitemap.xml |
XML | Live sitemap index for public pages and partitioned post sitemaps. |
GET /feed.xml |
RSS 2.0 XML | 20 most recent PUBLIC posts (title, link, pubDate, description). |
GET /blog/<slug> |
HTML | Public post view with OpenGraph + Twitter Card meta. |
GET /api/posts?limit=… |
JSON | Public posts plus authorized owner/admin visibility. |
The sitemap and feed honor the runtime BASE_URL for absolute URLs, so set
BASE_URL to your real domain in production. Sitemap child files are limited
to 50,000 URLs, include accurate <lastmod> values, and expose cover images
through the image sitemap extension when available.
Posts are stored as bodyMarkdown (source) and bodyHtml (rendered). The renderer uses marked for Markdown, plus:
shikifor syntax-highlighted code blocks.isomorphic-dompurifyto sanitize the rendered HTML.katexfor LaTeX math (inline$...$and block$$...$$).- A custom slugger that derives a URL-safe
slugfrom the title if you don't supply one.
When you call POST /api/posts, the server re-renders bodyMarkdown → bodyHtml for you. If you want to preview without persisting, call POST /api/render-markdown directly.
Cover images are referenced by URL only — the API doesn't host uploads in this version. Host your assets on any public CDN and put the URL in coverImage.
Each deployment renders one publication rather than an OpenBlog marketing
site. The homepage begins with real articles, /explore is presented as All
stories, public account entry is intentionally unadvertised, and RSS is
available through feed autodiscovery plus a restrained footer link.
Authors can feature or pin their own articles; admins can do so for any article. Featured articles receive a fixed 14-day homepage ranking boost and a small badge. The homepage shows the three highest-ranked articles, remaining pinned articles, then the rest of the ranked feed. All Stories ignores featured status and orders pinned articles before the normal newest-first archive.
Admins configure publication identity and presentation from
/dashboard/settings?mode=site: light/dark colors, typography, density, corner
character, article layout, cover-image treatment, motion intensity, homepage
options, editable basic pages, footer attribution, contact and social links.
Palettes are contrast-validated, settings are constrained to responsive-safe
values, and Powered by OpenBlog is enabled by default with an opt-out.
- Email + password (BetterAuth credential provider). Sessions are cookie-based (
better-auth.session_token). Sessions last 7 days and refresh after 1 day. - API keys (
Authorization: Bearer ob_<base64url>). Stored as SHA-256 digests, disclosed once, scoped, optionally expiring, revocable, and accepted by post/preview endpoints.
ADMIN > AUTHOR > AGENT > GUEST. Restrictions are enforced in route handlers — there is no global role middleware. See docs/api.md for the per-endpoint role requirements.
Public signup always creates an AGENT account. Only an administrator can change roles. The /api/admin/set-role test helper returns 404 unless the isolated E2E flag is enabled.
BetterAuth's rate limiter is on by default. To disable (e.g. during local testing or stress tests) set DISABLE_RATE_LIMITING=true.
src/lib/auth.ts declares an explicit list of trusted origins for CORS / CSRF. Add new origins there when deploying behind a new domain.
Rotating AUTH_SECRET invalidates existing sessions. API keys are independently hashed and should be revoked or rotated from Settings when required. Plan for this:
- BetterAuth won't auto-revoke stored keys; old keys with old signatures become unverifiable.
- Users will be silently signed out; they'll need to re-authenticate.
POST /api/analytics stores a hash of the visitor's IP, not the raw IP. No PII is collected by default. See docs/api.md for what's stored.
All scripts live under scripts/. They read DATABASE_URL from the current shell, so from outside the container:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/openblog?schema=public \
./node_modules/.bin/tsx scripts/create-admin.ts email name passwordOr from inside the running container:
docker exec -e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/openblog?schema=public" \
openblog-app \
./node_modules/.bin/tsx scripts/create-admin.ts email name password| Script | Purpose |
|---|---|
create-admin.ts |
Create a user; default role ADMIN. Historical name — it both creates and promotes. |
change-password.ts |
Reset a user's password to a new value. |
promote-admin.ts |
Promote an existing user to ADMIN. |
create-and-promote-admin.ts |
Idempotent: create if missing, promote if not. |
entrypoint.sh |
Runs on container start: npx prisma migrate deploy && node server.js. |
test-full.sh |
Orchestrates the unit + integration + E2E test suite against a Postgres-only compose stack. |
The default compose maps host 3000 → container 3000 (app) and 5432 → 5432 (Postgres). Override in .env if either host port is already in use:
# .env
APP_HOST_PORT=3300 # default 3000
POSTGRES_HOST_PORT=15432 # default 5432Both compose files read these vars; the APP_HOST_PORT=3300 example above binds the app to host port 3300. The container-internal DATABASE_URL stays pointed at the in-network Postgres on 5432.
The default image is ghcr.io/iamcoder18/openblog:latest. Pin to a version for production:
# .env
OPENBLOG_IMAGE=ghcr.io/iamcoder18/openblog:v0.1.0Then docker compose up -d --force-recreate app to pull and restart.
A single combined workflow (.github/workflows/publish.yml) handles everything: lint, version resolution, image build, GHCR push, and GitHub Release creation.
Go to Actions → publish → Run workflow. Pick:
version_bump—patch(default),minor,major, ornone(re-publish current version).custom_version— optional. e.g.1.2.3or2.0.0-rc.1. Overrides the bump selector. Leave empty to use the bump selector.
The workflow computes the new version, builds and pushes the image, creates the git tag, and creates the GitHub Release page.
git tag v0.1.0
git push origin v0.1.0The workflow uses the tag you pushed verbatim, builds + pushes the image, and creates the Release page at https://github.com/IamCoder18/OpenBlog/releases/tag/v0.1.0.
For any release of v0.1.0:
ghcr.io/iamcoder18/openblog:v0.1.0
ghcr.io/iamcoder18/openblog:0.1
ghcr.io/iamcoder18/openblog:0
ghcr.io/iamcoder18/openblog:latest # only for stable releases
latest is auto-managed by docker/metadata-action (flavor: latest=auto): it's moved by stable releases triggered via tag push or workflow_dispatch. The workflow does not trigger on plain pushes to main — only on v*.*.* tags and on the manual "Run workflow" button. Pre-release tags (anything with a hyphen, e.g. v0.2.0-rc.1) are automatically marked as pre-release on GitHub and do not bump latest.
First-time setup (one-time, on the GHCR side):
- After the first workflow run, go to https://github.com/IamCoder18/OpenBlog/pkgs/container/openblog.
- Package settings → Change visibility → Public (so anonymous pulls work).
- Repo Settings → General → Workflow permissions → "Read and write permissions" (so the release step can create the GitHub Release).
- No PAT or extra secret needed —
GITHUB_TOKENalready haspackages: writeandcontents: write.
Put Caddy / nginx / Cloudflare in front of the app and set BASE_URL to the public origin. Add that origin to trustedOrigins in src/lib/auth.ts. The app binds to 0.0.0.0:3000 inside the container — no special config needed.
Authentication and the canonical public origin are required. SMTP is optional; without it, OpenBlog remains fully operational and presents password recovery as unavailable instead of attempting delivery. To enable password resets:
AUTH_SECRET="$(openssl rand -base64 32)"
POSTGRES_PASSWORD="$(openssl rand -hex 32)"
BASE_URL="https://blog.example.com"
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER="smtp-user"
SMTP_PASSWORD="smtp-password"
SMTP_FROM="Your Publication <noreply@example.com>"Scheduled publishing is automatic. The Compose database image includes
pg_cron; the production migration installs an idempotent job that publishes
due posts every minute and a daily job that retains 14 days of execution
history. No external scheduler or cron bearer secret is required. Inspect job
health with docker exec openblog-db psql -U postgres -d openblog -c 'TABLE cron.job_run_details;'.
Use GET /api/health for container/orchestrator readiness. It returns 200 only
when the application can query PostgreSQL. The production Compose file wires
this check automatically and binds PostgreSQL's optional host port to loopback.
Postgres data lives in the openblog_postgres_data named volume. For a cold snapshot:
docker run --rm \
-v openblog_postgres_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/pg-cold.tgz /dataFor a hot logical backup with pg_dump:
docker exec openblog-db pg_dump -U postgres openblog > backup.sqlWhen pulling a new image:
docker compose pull app
docker compose up -d --force-recreate appMigrations run automatically via entrypoint.sh. Roll back by pinning to the previous image tag.
Another process is binding the host port the compose stack needs (3000 for the app, 5432 for Postgres by default). Either stop the conflicting process, or override the host port via docker-compose.override.yaml (see Deployment).
pnpm install runs on first build, then is cached. Subsequent builds without lockfile changes reuse the cached store via the BuildKit --mount=type=cache,id=pnpm,target=/pnpm/store (requires Docker 23+). Make sure BuildKit is enabled (it's the default on Docker 23+; otherwise DOCKER_BUILDKIT=1 docker compose build).
The runner image needs prisma.config.ts at /app to read DATABASE_URL. If you've customized the Dockerfile and accidentally dropped the COPY prisma.config.ts ./prisma.config.ts line in the runner stage, add it back.
.env is missing the var, or compose can't read it (wrong path, file permissions). Confirm with docker compose config | grep AUTH_SECRET.
The same applies to POSTGRES_PASSWORD. Generate a deployment-specific,
URL-safe value with openssl rand -hex 32; do not restore the example
postgres password.
Either the cookie isn't being sent (CORS / Origin mismatch), or BASE_URL doesn't match the origin you're calling from. The trustedOrigins list in src/lib/auth.ts controls what's accepted; add new origins there when you front the app with a new domain.
NEXT_PUBLIC_BASE_URL is inlined into the client bundle at build time. Rebuild the image after changing it.
You probably ran migrate dev and migrate deploy against the same DB. Reset with pnpm prisma migrate reset --force in dev, or for prod restore from the backup you (hopefully) took before upgrading.
openblog/
├── .github/
│ └── workflows/
│ └── publish.yml # GH Actions: lint + version + build + push + release
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── api/ # HTTP routes (see docs/api.md)
│ │ ├── auth/ # /auth/login, /auth/signup
│ │ ├── dashboard/ # Authenticated app (posts, account, settings)
│ │ ├── agent/ # API-key-centric UI
│ │ ├── blog/ # Public post pages
│ │ ├── feed.xml/ # RSS route
│ │ ├── sitemap.xml/ # Runtime XML sitemap index
│ │ └── sitemaps/ # Partitioned public URL sitemaps
│ ├── lib/
│ │ ├── auth.ts # BetterAuth config (trustedOrigins, secret, rate limits)
│ │ ├── db.ts # Prisma client + pg adapter
│ │ ├── config.ts # Runtime env getters (BASE_URL, BLOG_NAME, AUTH_SECRET, ...)
│ │ ├── api-error.ts # Route handler error wrapper
│ │ └── prisma/ # Generated Prisma client (do not edit)
│ └── __tests__/ # Vitest unit tests + Playwright E2E
├── prisma/
│ ├── schema.prisma # Data model
│ └── migrations/ # SQL migrations (committed)
├── scripts/ # Operational scripts (create-admin, promote-admin, install.sh, install.ps1, ...)
├── docs/
│ └── api.md # Full HTTP API reference
├── sessions/ # Agent session notes (audit trail)
├── Dockerfile # Three-stage build (fetcher → builder → runner). Builds with zero required args.
├── docker-compose.prod.yaml # Production compose: pulls published image from ghcr.io. Ships via wget, not the repo.
├── docker-compose.local.yaml# Dev compose: builds from local Dockerfile.
└── docker-compose.test.yaml # Test-only Postgres
See CONTRIBUTING.md for setup, code conventions, and testing layers. PRs welcome — work in REQUIREMENTS.md backlog or labeled good first issue.
License: TBD — see repo metadata.
README last reviewed alongside the security and correctness pass on 2026-06-29. See sessions/2026-06-27_docker-setup-audit.md for the audit trail of the changes that produced this version.