.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 indocker/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 v2.23+ (
docker compose, notdocker-compose) - 4 GB RAM and 10 GB disk to start with
The short way
.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}insidecompose.yamlis resolved from the.envnext 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.envand can be pointed elsewhere withENV_FILE.
.env.production.example documents every value; four have to be filled in 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.
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.
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
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.--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.5. Verify
6. TLS
TLS is not optional in practice: Telegram refusessetWebhook 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 thetls profile and Caddy obtains and renews Let’s Encrypt certificates
on its own — no certbot, no renewal cron, no reload hooks:
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.
Or terminate it yourself
If you already run Traefik, nginx or a cloud load balancer, leave the profile off and point it at theweb 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.
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.
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: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.
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:
web and app are on different versions. Pull both and recreate.