Files
landing-api/docker-compose.yml
Josep Piguillem 748b485c9b
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Add an MCP server for the API, deployable next to the application
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>
2026-09-27 21:58:14 +02:00

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