Skip to main content
Runs the whole stack in containers. The host needs Docker and nothing else — no PHP 8.4, no Composer, no Node. Everything below has been executed against this compose file; the troubleshooting section lists failures actually encountered rather than ones imagined.

What you get

Six services, defined in docker/compose.yaml: Two more sit behind Compose profiles: caddy for TLS with automatic certificates (--profile tls, see step 6), and the webhook gateway (--profile gateway, see Webhook gateway). Both are optional — the first only if you do not already terminate TLS elsewhere. app, horizon and scheduler deliberately share one image: they run identical code and differ only in the command. Building them separately would let their dependencies drift, which is how “works on the web tier, fails in the worker” happens.

Requirements

  • Docker Engine 24+ with the Compose plugin (docker compose, not docker-compose)
  • 4 GB RAM and 10 GB disk to start with
Read Requirements for what the application itself needs; the images already satisfy the runtime and extension list.

1. Get the files

Only docker/ and .env are needed at runtime — images come from the registry. The clone is the simplest way to obtain them and gives you the docs besides.

2. Configure .env

Compose reads this file twice, in two different ways, and the distinction matters:
  • Interpolation${DB_PASSWORD} inside compose.yaml is resolved from the file named by --env-file, which defaults to a .env next to the compose file. That is why every command below passes --env-file ../.env explicitly.
  • Container environmentenv_file: hands the whole file to the containers. It defaults to ../.env and can be pointed elsewhere with ENV_FILE.
The values that must be right before the first start:
DB_HOST and REDIS_HOST are overridden by Compose to the service names, so whatever they say is ignored inside containers. APP_ENV=production is not cosmetic. The images are built with --no-dev, and development tooling registered for the local environment is absent from them. Running a production image with APP_ENV=local used to fail at boot with a missing Telescope class; a guard now prevents that, but the setting is still wrong for anything but development. Empty passwords fail fast, by design. postgres and redis refuse to start without one, so Compose validates them up front rather than letting the stack half-start:

3. Start the stack

Or from docker/ with the shorthand, which carries the flags for you:
First start pulls the images and initialises the database volume. Watch it settle:
postgres and redis should reach healthy before app starts — that ordering is enforced by health checks, not by sleeps.

4. Install

The wizard generates the application key if there is none, verifies the database and Redis connections, applies the landlord migrations, and provisions the first tenant with its administrator. Steps that are already done are skipped, so it is safe to re-run after fixing something. The containers hold a cached config from startup, so restart them once the wizard has written new values:
Until a tenant exists the site answers 500 — the request cannot be resolved to a tenant. That is expected before this step, not a fault.

5. Verify

A growing queue with no movement means Horizon is not consuming — see Services.

6. TLS

TLS is not optional in practice: Telegram refuses setWebhook without a certificate it trusts, so channels do not work over plain HTTP. There are two supported ways to get it.

Included: Caddy with automatic certificates

Enable the tls profile and Caddy obtains and renews Let’s Encrypt certificates on its own — no certbot, no renewal cron, no reload hooks:
Both domains must already resolve to this host — Caddy proves control over them by answering an HTTP challenge on port 80, so DNS comes first. While testing, uncomment acme_ca in docker/caddy/Caddyfile to use the staging CA. The production one rate-limits failed attempts per domain, and spending that budget on a typo in a DNS record locks you out for a week.

Or terminate it yourself

If you already run Traefik, nginx or a cloud load balancer, leave the profile off and point it at the web service on HTTP_PORT. Whatever you use must forward the request body unmodified. Webhook signatures are computed over the exact bytes the provider sent, so any middleware that rewrites, decompresses or re-encodes the body breaks verification for every channel. Compressing responses is fine. Set GATEWAY_TRUSTED_PROXIES to the address of the terminator. The gateway ignores X-Forwarded-For from anyone not listed — otherwise a caller could forge a client address and walk straight past the rate limit.

Building your own images

Solutions and Plugins are Composer packages, so they have to be inside the application image. Building is an explicit choice, made by merging an overlay:
Or make build && make up-built from docker/. The build definitions live in a separate file deliberately. Compose treats a service that declares a build: as one it should build: with both image: and build: present it compiles locally and never contacts the registry — even with pull_policy: always. Keeping them apart is what makes pulling the default. Both images come from docker/Dockerfile via targets core and web. They share one Dockerfile because the Filament theme imports CSS out of vendor/, so the front-end cannot be compiled without the Composer install — splitting them would mean installing dependencies twice, with two results that could differ. Note that packages/ is excluded from the build context. Those are separate git checkouts that composer dev:link symlinks over vendor/ during development; inside an image the linker would replace released packages with whatever happened to be checked out.

Upgrading

horizon:terminate lets running jobs finish and exits; Compose restarts the container with the new code. Workers hold the previous release in memory until this happens. See Upgrading and rollback for the details, including tenant migrations.

Troubleshooting

Containers keep the old image after a rebuild. Compose compares tags, not content, so rebuilding under the same tag changes nothing on its own:
horizon restarts in a loop. Check its logs first: it fails on start rather than degrading. A boot error affecting the whole application shows up here first because the web tier can still serve cached pages.
The site returns 500 right after installation. Almost always no tenant yet — run platform:install. Confirm with:
Changes to .env have no effect. Config is cached at container start for APP_ENV=production. Recreate the containers, or set SKIP_CACHE_WARMUP=1 while debugging. Running a second environment from the same files. Point ENV_FILE at another file and use a separate project name, so volumes and containers do not collide:
Assets 404 while pages load. The web image carries the compiled front-end, so this means web and app are on different versions. Pull both and recreate.