mcp/ exposes both APIs as MCP tools, one per operation in openapi.yaml, named after the Laravel route each lands on (public_projects_index, admin_pages_update, ...). Nothing is written by hand: the tools are derived from the document the target application serves, which the contract tests already hold to the truth. Two transports over one core: - stdio (`node mcp/server.mjs`), registered in .mcp.json, signing in with credentials from the environment and refreshing the token. - Streamable HTTP (`--http`), stateless, deployed as the landing-mcp service. It holds no credentials: each request brings its caller's Pireon Basic credentials or bearer token, so the API applies that person's permissions. Sessions live in memory keyed by a hash, refused credentials are remembered for a minute, and the spec is reloaded every minute so new endpoints appear without a redeploy. Left out on purpose: the token exchange, telemetry, and the contact and apply forms, which notify real people. The preview secret is never sent for anonymous callers, and is unset in the deployment by default. A CI job runs the Node tests, including the HTTP mode end to end against a fake application. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
11 KiB
YAML
284 lines
11 KiB
YAML
###############################################################################
|
|
# Production stack.
|
|
#
|
|
# Five containers and one database. Deliberately no Redis: Postgres holds the
|
|
# cache, the sessions and the queue, which for a site this size is plenty and
|
|
# is one less thing to run, back up and forget to secure. Swapping it in later
|
|
# is three environment variables.
|
|
#
|
|
# Every application value below comes from `${VAR}` interpolation, not
|
|
# `env_file:` — so this works the same way whichever way the variables reach
|
|
# `docker compose`: a real shell environment, a platform's own "Environment
|
|
# Variables" panel (Dokploy, Coolify, ...), or an explicit `--env-file`:
|
|
#
|
|
# docker compose --env-file .env.production up -d --build
|
|
#
|
|
# `APP_KEY` is deliberately not required here even though the application
|
|
# cannot run without it: `docker/entrypoint.sh` checks it instead, with a
|
|
# message that says how to generate one. Requiring it at this level would also
|
|
# break generating it in the first place — `docker compose run --rm --no-deps
|
|
# landing-app php artisan key:generate --show` has to work before `APP_KEY`
|
|
# exists.
|
|
#
|
|
# The web service is named `landing-app`, not `app`: Compose aliases a
|
|
# service to its own name on every network it joins, `dokploy-network`
|
|
# included, and that network is shared with whatever else Dokploy runs on the
|
|
# same host. A service called plain `app` collides with the equally common
|
|
# `app` another compose project puts on the same network — its containers
|
|
# start resolving `app` to *this* one, on a port nothing here is listening on.
|
|
# That is exactly what broke `oauth-iam`'s PHP-FPM upstream the moment both
|
|
# stacks were up together, and it stops happening the moment the name isn't
|
|
# generic enough to collide.
|
|
#
|
|
# `landing-app` is not published on a host port either: it only joins the
|
|
# external `dokploy-network` so Dokploy's Traefik can reach it, and TLS is
|
|
# terminated there before anything reaches this container — same reason
|
|
# `docker/Caddyfile` trusts `X-Forwarded-*` only from private ranges. The
|
|
# domain itself is configured through Dokploy's own "Domains" tab, which
|
|
# injects its own Traefik labels — don't also add them here, Traefik ends up
|
|
# with two routers for the same Host() and picks one unpredictably.
|
|
#
|
|
# A manual deploy without Dokploy needs its own reverse proxy joined to that
|
|
# network (with its own labels/config), or `landing-app` added back to
|
|
# `default` with a published port.
|
|
#
|
|
# The web container is the only one that migrates, so a deploy runs them once
|
|
# rather than once per replica.
|
|
###############################################################################
|
|
|
|
name: landing
|
|
|
|
x-app: &app
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
image: pireon-admin:latest
|
|
restart: unless-stopped
|
|
volumes:
|
|
# Uploaded images live here, and they are the one thing in this stack that
|
|
# cannot be rebuilt from the repository.
|
|
- storage:/app/storage
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
|
|
x-app-env: &app-env
|
|
APP_NAME: ${APP_NAME:-Pireon Admin}
|
|
APP_ENV: ${APP_ENV:-production}
|
|
APP_KEY: ${APP_KEY}
|
|
APP_DEBUG: ${APP_DEBUG:-false}
|
|
APP_URL: ${APP_URL}
|
|
APP_LOCALE: ${APP_LOCALE:-es}
|
|
APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-es}
|
|
|
|
LOG_CHANNEL: ${LOG_CHANNEL:-stack}
|
|
LOG_STACK: ${LOG_STACK:-single}
|
|
LOG_LEVEL: ${LOG_LEVEL:-warning}
|
|
|
|
# Fixed by the service names below, not something a deploy should override —
|
|
# otherwise the panel and the API could disagree about the site's state.
|
|
DB_CONNECTION: pgsql
|
|
DB_HOST: postgres
|
|
DB_PORT: 5432
|
|
DB_DATABASE: ${DB_DATABASE:-pireon}
|
|
DB_USERNAME: ${DB_USERNAME:-pireon}
|
|
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
|
|
CACHE_STORE: database
|
|
SESSION_DRIVER: database
|
|
QUEUE_CONNECTION: database
|
|
|
|
PIREON_SERVER: ${PIREON_SERVER:-https://oauth.pireon.dev}
|
|
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID}
|
|
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET}
|
|
PIREON_REDIRECT_URI: ${PIREON_REDIRECT_URI}
|
|
PIREON_SCOPES: ${PIREON_SCOPES:-account:read}
|
|
PIREON_PASSWORD_GRANT: ${PIREON_PASSWORD_GRANT:-true}
|
|
PIREON_INTROSPECTION_TTL: ${PIREON_INTROSPECTION_TTL:-60}
|
|
PIREON_DEFAULT_ROLE: ${PIREON_DEFAULT_ROLE:-viewer}
|
|
PIREON_REQUIRE_ACTIVE: ${PIREON_REQUIRE_ACTIVE:-true}
|
|
PIREON_REQUIRE_APPROVED: ${PIREON_REQUIRE_APPROVED:-true}
|
|
|
|
SITE_URL: ${SITE_URL}
|
|
SITE_PREFIX_DEFAULT_LOCALE: ${SITE_PREFIX_DEFAULT_LOCALE:-false}
|
|
SITE_CACHE_SECONDS: ${SITE_CACHE_SECONDS:-60}
|
|
SITE_PREVIEW_TOKEN: ${SITE_PREVIEW_TOKEN}
|
|
SITE_RATE_LIMIT: ${SITE_RATE_LIMIT:-600}
|
|
SITE_TRUSTED_IPS: ${SITE_TRUSTED_IPS:-}
|
|
SITE_INGEST_TOKEN: ${SITE_INGEST_TOKEN}
|
|
SITE_TELEMETRY_RATE_LIMIT: ${SITE_TELEMETRY_RATE_LIMIT:-60}
|
|
SITE_TELEMETRY_MAX_BATCH: ${SITE_TELEMETRY_MAX_BATCH:-50}
|
|
SITE_ACCEPT_SUBMISSIONS: ${SITE_ACCEPT_SUBMISSIONS:-true}
|
|
SITE_HONEYPOT_FIELD: ${SITE_HONEYPOT_FIELD:-website}
|
|
SITE_SUBMISSION_RATE_LIMIT: ${SITE_SUBMISSION_RATE_LIMIT:-3}
|
|
SITE_SUBMISSION_HOURLY_LIMIT: ${SITE_SUBMISSION_HOURLY_LIMIT:-60}
|
|
SITE_SUBMISSION_DEDUPE_MINUTES: ${SITE_SUBMISSION_DEDUPE_MINUTES:-1440}
|
|
SITE_SUBMISSION_MAIL: ${SITE_SUBMISSION_MAIL:-false}
|
|
|
|
MEDIA_DISK: ${MEDIA_DISK:-public}
|
|
MEDIA_MAX_PIXELS: ${MEDIA_MAX_PIXELS:-50000000}
|
|
|
|
GIT_PROVIDER: ${GIT_PROVIDER:-gitea}
|
|
GITEA_URL: ${GITEA_URL:-}
|
|
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
|
GITEA_ORG: ${GITEA_ORG:-}
|
|
GIT_SYNC_CRON: ${GIT_SYNC_CRON:-hourly}
|
|
|
|
MAIL_MAILER: ${MAIL_MAILER:-log}
|
|
MAIL_HOST: ${MAIL_HOST:-}
|
|
MAIL_PORT: ${MAIL_PORT:-587}
|
|
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
|
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org}
|
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin}
|
|
|
|
services:
|
|
landing-app:
|
|
<<: *app
|
|
environment:
|
|
<<: *app-env
|
|
# Only here: migrations run once per deploy, not once per container.
|
|
RUN_MIGRATIONS: "true"
|
|
expose:
|
|
- "8080"
|
|
networks:
|
|
- dokploy-network
|
|
- default
|
|
|
|
# No Traefik labels here on purpose: the domain is configured through
|
|
# Dokploy's own "Domains" tab for this app, which injects its own labels
|
|
# at deploy time. Hand-writing another set on top of those, for the same
|
|
# Host(), gave Traefik two routers to choose between — it only broke once
|
|
# both existed together, which is exactly what happened.
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/up"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 40s
|
|
retries: 3
|
|
|
|
# Image processing and the inbox notifications. Without this, uploads serve
|
|
# without dimensions or srcset and the panel's bell stays silent — the site
|
|
# keeps working, which is exactly why the absence is easy to miss.
|
|
worker:
|
|
<<: *app
|
|
command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=3"]
|
|
environment: *app-env
|
|
|
|
# Waits for the web container, not just the database: it is the one that
|
|
# migrates, and a worker that starts against an empty schema spends its
|
|
# first minute crash-looping on tables that do not exist yet.
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
landing-app:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD", "php", "artisan", "queue:monitor", "default"]
|
|
interval: 60s
|
|
timeout: 10s
|
|
start_period: 40s
|
|
retries: 3
|
|
stop_grace_period: 30s
|
|
|
|
# Closes expired calls, sweeps up images the queue missed and refreshes the
|
|
# git repositories. `schedule:work` is the long-running form of a crontab.
|
|
scheduler:
|
|
<<: *app
|
|
command: ["php", "artisan", "schedule:work"]
|
|
environment: *app-env
|
|
|
|
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin
|
|
# API, and this container does not serve HTTP — inherited, it could never
|
|
# pass. Replaced rather than disabled, because `compose up --wait` refuses
|
|
# a service with no healthcheck at all.
|
|
#
|
|
# What it proves is modest and stated plainly: the application still boots
|
|
# and can read its schedule. It does not prove the loop is running; if that
|
|
# dies, the process dies with it and `restart: unless-stopped` brings it
|
|
# back.
|
|
healthcheck:
|
|
test: ["CMD", "php", "artisan", "schedule:list"]
|
|
interval: 60s
|
|
timeout: 15s
|
|
start_period: 40s
|
|
retries: 3
|
|
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
landing-app:
|
|
condition: service_healthy
|
|
stop_grace_period: 30s
|
|
|
|
# The MCP server (mcp/), so Claude and other MCP clients can use the API
|
|
# from anywhere, not only from a checkout of this repository. It reaches the
|
|
# application over the stack's own network rather than the public domain:
|
|
# one hop, no TLS, and it keeps working while the domain is misconfigured.
|
|
#
|
|
# Holds no credentials of its own. Every request brings its caller's Pireon
|
|
# credentials, and the API applies that person's permissions — see the
|
|
# README's "Servidor MCP" section for how to connect.
|
|
#
|
|
# Like `landing-app`, published only through `dokploy-network`: add its
|
|
# domain in Dokploy's "Domains" tab, pointing at port 3000, and the endpoint
|
|
# is https://<that domain>/mcp.
|
|
landing-mcp:
|
|
build:
|
|
context: ./mcp
|
|
image: landing-mcp:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
LANDING_URL: http://landing-app:8080
|
|
LANDING_MCP_REQUIRE_AUTH: ${LANDING_MCP_REQUIRE_AUTH:-false}
|
|
# Deliberately not SITE_PREVIEW_TOKEN by default: with it, anyone who can
|
|
# sign in sees every draft through the public tools, whatever their role
|
|
# would let them see in the panel. Set it to that value knowingly.
|
|
LANDING_PREVIEW_TOKEN: ${LANDING_MCP_PREVIEW_TOKEN:-}
|
|
expose:
|
|
- "3000"
|
|
networks:
|
|
- dokploy-network
|
|
- default
|
|
depends_on:
|
|
landing-app:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/up').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 10s
|
|
retries: 3
|
|
|
|
postgres:
|
|
image: postgres:17-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_DB: ${DB_DATABASE:-pireon}
|
|
POSTGRES_USER: ${DB_USERNAME:-pireon}
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
|
|
|
|
# Content is written by a handful of people and read constantly; the
|
|
# default collation rules are what the JSON slug lookups sort under.
|
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-pireon} -d ${DB_DATABASE:-pireon}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 20s
|
|
|
|
# Not published: nothing outside this stack has any business reaching the
|
|
# database. Use `docker compose exec postgres psql` to get in.
|
|
expose:
|
|
- "5432"
|
|
|
|
volumes:
|
|
pgdata:
|
|
storage:
|
|
|
|
networks:
|
|
dokploy-network:
|
|
external: true
|