> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fapost.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Docker Compose

> The container installation path, start to finish.

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.

<Note>
  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.
</Note>

## What you get

Six services, defined in [`docker/compose.yaml`](https://github.com/fapost-lab/core/blob/main/docker/compose.yaml):

| Service     | Image                         | Role                              |
| ----------- | ----------------------------- | --------------------------------- |
| `web`       | `ghcr.io/fapost-lab/web`      | nginx with the compiled front-end |
| `app`       | `ghcr.io/fapost-lab/core`     | PHP-FPM                           |
| `horizon`   | same image, different command | queue workers                     |
| `scheduler` | same image, different command | one-minute tick                   |
| `postgres`  | `postgres:16-alpine`          | database                          |
| `redis`     | `redis:7-alpine`              | queues, cache, webhook registry   |

Two more sit behind Compose profiles: `caddy` for TLS with automatic
certificates (`--profile tls`, see [step 6](#6-tls)), and the webhook gateway
(`--profile gateway`, see [Webhook gateway](/self-hosting/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`, not `docker-compose`)
* 4 GB RAM and 10 GB disk to start with

Read [Requirements](/self-hosting/requirements) for what the application itself needs;
the images already satisfy the runtime and extension list.

## The short way

```bash theme={"theme":"one-dark-pro"}
sh -c "$(curl -fsSL https://get.fapost.in/install.sh)"
```

The [installer](https://github.com/fapost-lab/install) 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.

<Note>
  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.
</Note>

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

```bash theme={"theme":"one-dark-pro"}
mkdir fapost && cd fapost
curl -fsSLO https://raw.githubusercontent.com/fapost-lab/core/main/docker/compose.yaml
curl -fsSL  https://raw.githubusercontent.com/fapost-lab/core/main/.env.production.example -o .env
```

<Note>
  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.
</Note>

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

```dotenv theme={"theme":"one-dark-pro"}
APP_KEY=                       # generated below
APP_URL=https://fapost.example.com
TENANCY_BASE_DOMAIN=fapost.example.com
TENANT_SLUG=app

DB_PASSWORD=<a real password>  # must not be empty
REDIS_PASSWORD=<a real password>
```

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.

```bash theme={"theme":"one-dark-pro"}
printf 'APP_KEY=base64:%s\n' "$(openssl rand -base64 32)"
printf 'DB_PASSWORD=%s\nREDIS_PASSWORD=%s\n' "$(openssl rand -hex 24)" "$(openssl rand -hex 24)"
```

`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:

```
required variable DB_PASSWORD is missing a value
```

## 3. Start the stack

```bash theme={"theme":"one-dark-pro"}
docker compose up -d
```

First start pulls the images and initialises the database volume. Watch it settle:

```bash theme={"theme":"one-dark-pro"}
docker compose ps
```

`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.

```bash theme={"theme":"one-dark-pro"}
printf '%s' '<the admin password>' | docker compose exec -T app \
  php artisan platform:install \
    --tenant-slug=app \
    --admin-email=admin@fapost.example.com \
    --admin-password-file=-
```

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.

<Note>
  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`.
</Note>

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

```bash theme={"theme":"one-dark-pro"}
docker compose ps                       # every service up, postgres/redis healthy
curl -I http://localhost:8000/          # 200 once a tenant exists
curl -I http://localhost:8000/build/manifest.json    # 200 — assets are being served
docker compose exec app php artisan horizon:status   # workers running
docker compose logs -f horizon
```

A growing queue with no movement means Horizon is not consuming — see
[Services](/self-hosting/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:

```dotenv theme={"theme":"one-dark-pro"}
APP_DOMAIN=fapost.example.com        # must equal TENANCY_BASE_DOMAIN
ACME_EMAIL=ops@example.com
GATEWAY_DOMAIN=webhook.example.com   # only if you deploy the gateway

# Keep the plain-HTTP ports off the public interface, or they are a way around
# TLS entirely.
HTTP_BIND=127.0.0.1
GATEWAY_BIND=127.0.0.1
```

```bash theme={"theme":"one-dark-pro"}
docker compose --profile tls up -d
```

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.

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

```bash theme={"theme":"one-dark-pro"}
GATEWAY_TRUSTED_PROXIES=172.16.0.0/12
```

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.

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

```bash theme={"theme":"one-dark-pro"}
cd docker && make build && make up-built
```

Or written out, from the project root:

```bash theme={"theme":"one-dark-pro"}
ENV_FILE=../.env docker compose -f docker/compose.yaml -f docker/compose.build.yaml build
ENV_FILE=../.env docker compose -f docker/compose.yaml -f docker/compose.build.yaml up -d
```

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`](https://github.com/fapost-lab/core/blob/main/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

```bash theme={"theme":"one-dark-pro"}
docker compose pull
docker compose up -d
docker compose exec app php artisan migrate --database=landlord --path=database/migrations/landlord --force
docker compose exec app php artisan ops:tenants-migrate
docker compose exec app php artisan horizon:terminate
```

`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](/self-hosting/upgrading) 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:

```bash theme={"theme":"one-dark-pro"}
docker compose up -d --force-recreate
```

**`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.

```bash theme={"theme":"one-dark-pro"}
docker compose logs horizon --tail 30
```

**The site returns 500 right after installation.** Almost always no tenant yet —
run `platform:install`. Confirm with:

```bash theme={"theme":"one-dark-pro"}
docker compose exec app \
  php artisan tinker --execute='echo App\Domains\Tenancy\Models\Tenant::query()->count();'
```

**`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:

```bash theme={"theme":"one-dark-pro"}
ENV_FILE=.env.staging docker compose -p fapost-staging \
  --env-file .env.staging up -d
```

**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.

## Related

* [Deployment overview](/self-hosting/overview)
* [Requirements](/self-hosting/requirements)
* [Long-running services](/self-hosting/services)
* [Webhook gateway](/self-hosting/gateway)
* [Bare metal](/self-hosting/bare-metal)
