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. An installation is one compose file and one .env beside it. The troubleshooting section lists failures actually encountered rather than ones imagined.
Requires Compose v2.23 or newer — check with docker compose version. The compose file carries the configuration files its services need inline, and inline config content is not supported before that release.

What you get

Six services, defined in docker/compose.yaml: Three more sit behind Compose profiles: caddy for TLS with automatic certificates (--profile tls, see step 6), the webhook gateway (--profile gateway, see Webhook gateway), and reverb for websocket live updates (--profile realtime, see Realtime). All 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 v2.23+ (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.

The short way

The installer performs every step below: it checks the host, asks for the domain and the administrator’s email, generates the application key and the two service passwords, writes the .env, starts the stack and creates the first tenant. Use it unless you want to see what it does — which is what the rest of this page is.
Written as sh -c "$(curl …)" rather than curl … | sh deliberately: the second form hands the script itself to standard input, leaving nothing for the questions to be answered on.

1. Get the files

An installation is two files in a directory of its own — no clone, no directory layout to reproduce. compose.yaml stands alone: images come from the registry, and every configuration file the services need is inlined in it.
Working from a clone instead? The application’s .env sits at the project root there rather than beside the compose file, so the commands need ENV_FILE=../.env to point the containers at it. Running make from docker/ carries that for you.

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 .env next to the compose file, which is picked up automatically.
  • Container environment — env_file: hands the whole file to the containers. It defaults to that same .env and can be pointed elsewhere with ENV_FILE.
.env.production.example documents every value; four have to be filled in before the first start.
The key and the two passwords can be generated on the spot. Do the key before the first start: containers cache their configuration at boot, so a key written afterwards is one the running processes have not read.
DB_HOST and REDIS_HOST are overridden by Compose to the service names, so whatever they say is ignored inside containers. The panel is not served from the base domain. TENANCY_BASE_DOMAIN carries the welcome page and is reserved for a control plane; the admin panel and the assistant console are served from the tenant’s own host, TENANT_SLUG prefixed to it. With the values above that is app.fapost.example.com — two names, both of which must resolve to this host. TENANCY_RESOLUTION=single serves that one tenant, whatever host the request names. host serves any number of tenants, each from its own <slug>.<TENANCY_BASE_DOMAIN> host; see Several tenants below for what that asks of DNS, certificates and sessions. Panel domains are fixed when the routes are registered, so after changing TENANCY_RESOLUTION the route cache has to be rebuilt (php artisan route:cache, which the Docker entrypoint does on start). 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

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. Create the first tenant

Nothing is provisioned automatically: migrations and tenant creation are an operator’s decision, not something three replicas race each other through on boot.
This applies the landlord migrations, creates the tenant schema, runs the tenant migrations, bootstraps the ACL and creates the administrator. The password comes in on standard input so it never appears in a process list; --admin-password takes it as an argument instead, and omitting both prompts for it. The panel is then at https://app.fapost.example.com/admin — the tenant host, not the base domain.
Prefer to be walked through it? docker compose exec app php artisan install is a wizard that verifies each connection before writing it, generates the application key if there is none, and ends by calling the command above. It writes to .env as it goes, so recreate the containers afterwards — they cached their configuration at boot: docker compose up -d --force-recreate app horizon scheduler.
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:
Caddy gets a certificate for two application names: APP_DOMAIN, and the tenant host that the panel is served from. The second is derived from TENANT_SLUG and APP_DOMAIN rather than configured separately, which is why APP_DOMAIN has to match TENANCY_BASE_DOMAIN — a mismatch means a certificate for a name nothing answers on, and none for the panel. Override the derived value with PANEL_DOMAIN if your deployment does not follow that shape. Every name must already resolve to this host — Caddy proves control over each by answering an HTTP challenge on port 80, so DNS comes first. While testing, switch to the staging CA by uncommenting the acme_ca line in the caddyfile config at the bottom of compose.yaml. 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.

The client address behind a proxy

Behind Caddy the application’s own peer (PHP’s REMOTE_ADDR) is Caddy, so without help every visitor would show up with Caddy’s address, and anything limited per client (sign-up, login attempts) would count them all as one. TRUSTED_PROXIES lists the addresses allowed to say who the client is through X-Forwarded-For:
  • Unset, the Compose file trusts one address: the fixed one it gives the caddy service on the compose network (172.30.0.2 in 172.30.0.0/24, outside the dynamic range 172.30.0.128/25; move all three with CADDY_ADDRESS, COMPOSE_SUBNET and COMPOSE_IP_RANGE if the range collides with a network on your host or with a second installation from the same file). Without the tls profile nothing sits at that address, so nobody is trusted. Nothing else on the network, and nobody reaching HTTP_PORT directly, can choose the address the application sees.
  • A comma-separated list of addresses or CIDR ranges replaces it. An empty value trusts nobody, and then every client has the proxy’s address.
  • * trusts whoever connects directly. Use it only when nothing but your proxy can reach the application.
Set it in the .env beside compose.yaml: Compose interpolates the default from that file (or from --env-file), not from the file ENV_FILE points at, so a value that lives only in ENV_FILE is overridden by the default. Only the client address and scheme are believed from a trusted proxy. The host never is, because it selects the tenant, and neither is the port, which follows the scheme. Behind Cloudflare the client sits one hop further out. Add Cloudflare’s published ranges (cloudflare.com/ips) to TRUSTED_PROXIES next to Caddy’s address, and tell Caddy to keep the chain Cloudflare built by adding to the global block of the caddyfile config:
Core reads X-Forwarded-For, not Cloudflare’s CF-Connecting-IP; Cloudflare sets both.

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 terminator — an address or a CIDR range, and a range is what you want on a compose network, where Docker reassigns container addresses. 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. Entries are exact addresses or CIDR ranges, comma-separated. Prefer a range when the terminator runs as a container: its address on the compose network is handed out by Docker and changes whenever the container is recreated, so an exact one stops matching after the next up.
Check the actual subnet with docker network inspect if your daemon is configured with a different address pool. An entry the gateway cannot parse stops it from starting, rather than being dropped and leaving it trusting nothing.

Several tenants (host mode)

TENANCY_RESOLUTION=host turns one installation into many tenants, each at <slug>.<TENANCY_BASE_DOMAIN>. The base domain itself serves platform pages with no tenant. Any other host under the base domain that names no active tenant answers 404, and a host outside the base domain is refused with 400. Four things have to be true of the deployment. A wildcard DNS record and certificate. Point *.fapost.example.com at this host. A wildcard certificate can only be issued through the DNS challenge, so Caddy needs a build with a module for your DNS provider; the stock image does not have one.
Point Compose at the image you built, and declare the wildcard site in a file under caddy.d/ beside compose.yaml (CADDY_SITES_DIR moves the directory). The Caddyfile imports *.caddy from it, so with the directory empty nothing changes:
Add the token to the caddy service’s environment in your own override file, as the Compose file does not know your provider. Sites declared by name, such as GATEWAY_DOMAIN, keep winning over the wildcard. On-demand certificates are not supported yet: Caddy would need an endpoint to ask whether a name belongs to a tenant before it requests a certificate, and Core does not provide one. Without it anyone could make Caddy request certificates for names you never meant to serve. A session driver that is not database. Platform pages run a session with no tenant, and the database driver keeps its table inside a tenant schema. The application refuses to serve web requests in this combination and says so; redis is the default in .env.production.example. Host-only session cookies. Leave SESSION_DOMAIN unset. A domain such as .fapost.example.com sends one tenant’s session cookie to every other tenant’s host, and the application refuses to start web requests with it set. Ingress hosts under the base domain. The application answers only hosts under TENANCY_BASE_DOMAIN, webhook ingress included, so WEBHOOK_BASE_URL and the gateway’s public host must sit under it. A request to anything else, such as an internal probe by container name, gets 400. php artisan about lists a setting that breaks this. The gateway forwards the host the provider called when it falls back to the application, so that path is covered. Horizon (and Telescope in development) answer on the base domain and on declared platform subdomains only, never on a tenant’s host. Both are closed until a user passes the viewHorizon gate, and platform pages carry no tenant sign-in, so for now watch the queues with horizon:status and the logs; an operator package that brings its own sign-in opens the dashboard. php artisan install asks for the mode when .env does not state one, and sets SESSION_DRIVER=redis for you if you choose host. Moving an existing install to host is described under Upgrading.

Building your own images

Solutions and Plugins are Composer packages, so they have to be inside the application image. This is the one path that needs a clone: the build context is the repository, not a compose file on its own. Building is then an explicit choice, made by merging an overlay:
Or written out, from the project root:
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:
caddy exits immediately with “server block without any key”. A site address resolved to an empty string. GATEWAY_DOMAIN is the usual cause: leave it unset or empty and compose supplies an inert placeholder, but a name that resolves to nothing useful — a stray space, a half-edited value — becomes a block Caddy cannot parse, and it refuses to start rather than serve part of the config. 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.