Files
landing-api/docker-compose.yml
Josep 080c4238e8
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
remove port
2026-08-22 23:43:43 +02:00

232 lines
8.6 KiB
YAML

###############################################################################
# Production stack.
#
# Four 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
# app php artisan key:generate --show` has to work before `APP_KEY` exists.
#
# `app` is not published on a host port: 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 `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:
app:
<<: *app
environment:
<<: *app-env
# Only here: migrations run once per deploy, not once per container.
RUN_MIGRATIONS: "true"
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/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
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
app:
condition: service_healthy
stop_grace_period: 30s
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