############################################################################### # 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 # 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 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