Production
In production, fepli runs from the same image as on your machine. What changes is around it: TLS, real secrets, backups and a routine for updates. This page shows a complete setup on one server with Docker Compose, and what to keep in mind on other platforms.
The image
fepli builds the image in its release workflow with Railpack and publishes it to the GitHub container registry as ghcr.io/ferienpass/app:general-latest. It contains PHP, the web server (FrankenPHP), every dependency and the compiled assets. You never build it yourself.
The tag moves: every release replaces the image behind it. To decide yourself when an update happens, deploy by digest. After a pull, docker images --digests ghcr.io/ferienpass/app shows it, and you pin it like this:
image: ghcr.io/ferienpass/app:general-latest@sha256:4f2a…
The Varnish image, ghcr.io/ferienpass/varnish:latest, is released together with it. Update both at the same time.
The image is built for linux/amd64. Log in to the registry on every host that pulls it:
docker login ghcr.io --username <your user name>
One server with Docker Compose
The setup of the local instance, with a TLS proxy in front, Varnish between the proxy and fepli, real secrets and no mail catcher. Put these files into one folder on the server. To run without Varnish, you take one service out.
compose.yaml
name: fepli
x-fepli: &fepli
image: ghcr.io/ferienpass/app:general-latest
env_file: fepli.env
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
volumes:
- files:/app/files
- storage:/app/storage
- images:/app/contao-assets/images
- share:/app/public/share
- indexes:/app/var/indexes
- deferred-images:/app/var/deferred-images
services:
proxy:
image: caddy:2
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '443:443/udp'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- varnish
varnish:
image: ghcr.io/ferienpass/varnish:latest
restart: unless-stopped
env_file: varnish.env
environment:
VARNISH_STORAGE: malloc,1g
depends_on:
- web
web:
<<: *fepli
# The port opens once the database is migrated; the worker and the cron wait for it.
healthcheck:
test: ['CMD', 'php', '-r', 'exit(@fsockopen("127.0.0.1", (int) (getenv("PORT") ?: 80)) ? 0 : 1);']
interval: 10s
start_period: 10m
worker:
<<: *fepli
environment:
IS_WORKER: '1'
depends_on:
web:
condition: service_healthy
cron:
<<: *fepli
command: ['while true; do php bin/console contao:cron; sleep 60; done']
depends_on:
web:
condition: service_healthy
mariadb:
image: mariadb:12.3
restart: unless-stopped
env_file: mariadb.env
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-buffer-pool-size=1G
healthcheck:
test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
interval: 10s
retries: 10
volumes:
- db:/var/lib/mysql
redis:
image: redis:8
restart: unless-stopped
command: ['redis-server', '--appendonly', 'yes', '--maxmemory-policy', 'noeviction']
volumes:
- redis:/data
volumes:
caddy-data:
caddy-config:
db:
redis:
files:
storage:
images:
share:
indexes:
deferred-images:
Caddyfile
{
email it@example.fepli.eu
}
example.fepli.eu, www.example.fepli.eu {
# Only fepli itself may purge the cache, from inside the network.
@invalidation method BAN PURGE PURGEKEYS
respond @invalidation 405
reverse_proxy varnish:80
}
mariadb.env
MARIADB_ROOT_PASSWORD=…
MARIADB_DATABASE=fepli
MARIADB_USER=fepli
MARIADB_PASSWORD=…
varnish.env
# openssl rand -hex 32, the same value as in fepli.env
VARNISH_PURGE_TOKEN=5b0e…
fepli.env is the complete example from the configuration, with your domain, your mail server, the password from mariadb.env in DATABASE_URL and the token from varnish.env in VARNISH_PURGE_TOKEN.
Notes on the pieces:
- Caddy gets and renews the certificates from Let's Encrypt on its own, once the domains point at the server. It passes the original host and scheme on, which fepli needs to build correct links. Any other proxy works if it does the same: it has to set
X-Forwarded-Proto, keep theHostheader and accept uploads of 32 MB (nginx:client_max_body_size 32m). - Forwarded headers. fepli takes the client's address and the scheme from the
X-Forwarded-ForandX-Forwarded-Protoheaders of requests from the addresses inTRUSTED_PROXIES. Its default covers the networks Docker gives a Compose project, so this setup doesn't need to set it. fepli ignores the otherX-Forwarded-*headers and takes the host fromHost. So the proxy must set these two headers itself rather than pass on what a client sends. Caddy does. - Varnish forwards every request to the service called
web, whatever the domain. It removesX-Forwarded-Host,X-Forwarded-Port,X-Forwarded-PrefixandForwardedfrom each request, and ignoresCache-Controlin requests without the purge token: a forced reload in the browser gets the cached page too.VARNISH_STORAGEis its cache size, 256 MB if you leave it out: the larger, the more pages it serves without asking fepli. It needsVARNISH_HOST=varnish:80infepli.env. - Purges. Varnish accepts
BAN,PURGEandPURGEKEYSfrom inside the private network only, and withVARNISH_PURGE_TOKENinvarnish.envonly when they carry that token. fepli sends it fromfepli.env, so both files need the same value: with the token on Varnish alone, every purge fails. Your proxy connects from the private network too, which is why it refuses the purge methods itself. - MariaDB: give the buffer pool about half of the memory you give MariaDB.
- Only the proxy publishes ports. MariaDB, Redis, Varnish and fepli are reachable from inside the Compose network only.
For several tenants, list all their domains and the platform console in the Caddyfile, comma-separated, as for Musterstadt.
Without Varnish
To run without Varnish (see With or without Varnish):
- Delete the
varnishservice fromcompose.yaml, and let the proxy depend onwebinstead.varnish.envis no longer needed. - In the Caddyfile, send requests to fepli directly:
reverse_proxy web:80. - In
fepli.env, setVARNISH_HOST=(empty).
fepli then purges nothing. To add Varnish later, do the three steps backwards.
First start
docker compose up -d
The web container creates fepli's tables before it starts to answer, and the worker and the cron start once it does. docker compose logs -f web shows the progress.
Then create the tenant and its first admin, as described in Single or multi-tenant. Point the domains' DNS at the server before you open them: Caddy needs them to get the certificates.
Updates
fepli announces what a release changes in the changelog. Back up the database first, then:
Update
docker compose pull web worker cron varnish
docker compose stop web worker cron
docker compose up -d
pulldownloads the new fepli images while the old ones keep running. If you pinned a digest, put the new one intocompose.yamlfirst. Leave outvarnishif you run without it. MariaDB, Redis and Caddy you update on your own schedule.stoptakes fepli offline, so no old container uses the database while the new version changes its structure. With Varnish, the pages it holds stay available; everything else answers with an error until the last step is done.up -dstarts the new version. The web container migrates the database before it starts to answer: it adds what the new version needs and removes what it no longer uses. With Varnish, it then clears the cache for every domain of every tenant and for the platform console. The worker and the cron start once the web container answers.
If the migration fails, the web container stops and the worker and the cron don't start. docker compose logs web shows why. Fix the cause, or go back to the previous image and restore the backup.
To run the migration yourself instead, set MIGRATE_ON_START=0 in fepli.env and run it between stop and up -d. Keep the quotes: the image runs its command through bash -c, so the whole command has to be one argument.
docker compose run --rm web 'php bin/console app:migrate --with-deletes --no-backup'
Rehearse an update on your local instance or a staging instance before you run it in production. With the digest pinned, production then gets exactly the image you tried.
Backups
Back up two things, daily, and keep the copies away from the server. The copies hold the families' data:
- Encrypt them before they leave the server, with a key whose private part is not on the server. The examples use age and the public part of your backup key (
age1…). - Set a retention period, and delete older copies when it ends. A copy keeps data that fepli itself deletes when its retention period ends.
What to back up:
-
The database. It holds everything except files. Dump it as a user that may only read, with the password in an option file rather than on the command line. Create the user once in the MariaDB client, which asks for the root password from
mariadb.env:docker compose exec mariadb mariadb -uroot -pCREATE USER 'backup'@'localhost' IDENTIFIED BY '…'; GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES ON fepli.* TO 'backup'@'localhost';Put the password into
backup.cnfnext tocompose.yaml, readable only by you (chmod 600):backup.cnf
[mariadb-dump] user=backup password=…Then dump, compress and encrypt. The dump reads
backup.cnffrom its input:docker compose exec -T mariadb \ mariadb-dump --defaults-extra-file=/dev/stdin --single-transaction fepli < backup.cnf \ | gzip | age -r age1… > fepli-$(date +%F).sql.gz.age -
The volumes.
filesandstoragehold what people uploaded and exported: logos, images of offers, export files. With object storage, attachments and each tenant's logo and sign-in picture are in the files bucket instead: back up that bucket as well. The other volumes hold thumbnails and the search index; back them up too if you want a restore without gaps. For example:docker run --rm -v fepli_files:/data:ro alpine tar czf - -C /data . \ | age -r age1… > files-$(date +%F).tgz.age
Redis holds jobs that haven't run yet and the sessions. It keeps them on disk, so a restart loses nothing, but it needs no backup.
A backup you haven't restored yet is a hope, not a backup. Restore one now and then into an instance that is protected like production. Don't use your local instance for it: the copy holds real families' data.
Scaling
When one server with the setup above gets busy:
-
Running without Varnish? Add it first. It answers most page views before they reach the web container.
-
More requests at once: raise
FRANKENPHP_MAX_THREADSand give the web container the memory for it, up to 512 MB per thread. -
More background jobs: add worker containers. Each one needs a consumer name of its own, as the last part of its queue address, or two workers pick up the same job:
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages/symfony/worker-2. -
More than one web container: possible once sessions and locks are in Redis (
SESSION_DSNandLOCK_DSN). The volumes then have to be shared storage that all of them can write to. When they start together, they take turns to migrate the database: the first does the work, the others find nothing left to do. -
The cron: always exactly one.
Other platforms
On Kubernetes, Nomad, Dokploy, Coolify or similar, keep to the same shape:
- Three workloads from one image with the same variables: the web (default command, port 80), the worker (
IS_WORKER=1) and the cron (one replica). - The image runs as root. Don't give it another user (Compose
user:, KubernetesrunAsUserorrunAsNonRoot): its start script and the web server expect root. - Varnish sends every request to the host
webon port 80. Name the web serviceweb, or give it that alias. With aVARNISH_PURGE_TOKEN, give Varnish the same value as the web, the worker and the cron. - Trusted proxies. On Docker Swarm, Dokploy and Kubernetes, your proxy and Varnish usually connect from
10.0.0.0/8, outside the default ofTRUSTED_PROXIES. Set it to the network they connect from. Otherwise fepli takes every request for plain HTTP, coming from the proxy's address. - The image runs its command through
/bin/bash -c. To replace the command, pass the whole command line as one argument. On Kubernetes, setargs, notcommand:commandreplaces the entrypoint. - The worker ends itself every five minutes to start with fresh memory, and relies on being restarted. Platforms that wait longer and longer before each restart, like Kubernetes, should run it in a loop instead:
args: ['while true; do php bin/console messenger:consume async --time-limit=300 --memory-limit=128M; done']. - Run the cron as a long-running loop, as in the Compose file, rather than as a job per minute that starts a new container each time.
- Volumes shared between the web and the worker, and between all web replicas.
- The web container migrates the database when it starts, before it opens its port. Give it a startup probe with time for that, e.g. a TCP probe on port 80 allowing ten minutes. Roll out an update by stopping the old containers first (on Kubernetes
strategy: Recreate): the migration removes columns the old version still reads. To migrate in a job of its own instead, setMIGRATE_ON_START=0and runphp bin/console app:migrate --with-deletes --no-backupbefore the new containers start. - One-off commands run in the web container:
kubectl exec, or the platform's terminal.
Delivery reports from Brevo
If fepli sends its e-mails through Brevo, Brevo can report what became of each one: delivered, deferred, bounced, blocked, reported as spam. fepli shows it in the admin's Postausgang and in the deliveryStatus of the API's sent messages. Register the webhook for these reports with the Brevo account of BREVO_API_KEY:
docker compose exec web php bin/console brevo --webhook=engagement
The webhook points at the domain in APP_BASE_URL, which Brevo must reach over HTTPS. Each tenant records the reports for its own e-mails, so on a multi-tenant installation register it once per tenant, with --tenant=<slug>: it then points at that tenant's domain. The webhook asks Brevo for delivery events only. fepli doesn't record whether recipients open an e-mail or follow a link in it: that needs their consent, and the image leaves it off. Whether Brevo puts a tracking pixel into the e-mails is a setting of your Brevo account.
A webhook registered with an earlier version also asked for opens and clicks. The command then says that the webhook has other events: run it again with --force to replace it. php bin/console mail:purge-tracking counts the opens and clicks recorded so far, and deletes them with --force.
Monitoring
- Logs: every container logs to its output.
docker compose logs -f web worker cronshows them. - Uptime:
https://example.fepli.eu/healthanswers200while the web container works. - Cron: set
STATUS_HEARTBEAT_URLto an uptime monitor's heartbeat address. The cron calls it every hour; if the calls stop, the monitor raises the alarm. - Errors: set
SENTRY_DSNto your own Sentry project. - The worker: if e-mails stop arriving, look at the worker's logs first.
Security checklist
- Only the proxy is reachable from outside. MariaDB, Redis and Varnish never publish a port.
TRUSTED_HOSTSlists your domains and nothing else.- The proxy sets
X-Forwarded-ForandX-Forwarded-Protoitself instead of passing on what clients send, and keepsHost. TRUSTED_PROXIEScovers the networks your proxy and Varnish connect from: its default in this Compose setup, your own value elsewhere.- With Varnish, the proxy refuses
BAN,PURGEandPURGEKEYSrequests from outside, and Varnish and fepli share aVARNISH_PURGE_TOKENof your own. - Only fepli's Varnish caches fepli's pages. A CDN in front of it does no harm, but caches nothing of fepli's either.
APP_SECRETandINTEGRATIONS_ENCRYPTION_KEYare your own, andfepli.env,mariadb.envandvarnish.envare readable only by you.ADMIN_EMAILis an address of yours, andSENTRY_DSN, if you set it, your own project's.APP_DEBUGstays0.- You update to new releases promptly: security fixes reach you only through new images.