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
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@musterstadt.de
}
ferienpass-musterstadt.de, www.ferienpass-musterstadt.de {
# 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=…
fepli.env is the complete example from the configuration, with your domain, your mail server and the password from mariadb.env in DATABASE_URL.
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). - Varnish forwards every request to the service called
web, whatever the domain. It accepts purges from private addresses only (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7, loopback). Your proxy connects from one of those too, which is why it refuses the purge methods itself.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. - 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. - 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 database. It holds everything except files:
docker compose exec -T mariadb sh -c \ 'mariadb-dump --single-transaction --routines -uroot -p"$MARIADB_ROOT_PASSWORD" fepli' \ | gzip > fepli-$(date +%F).sql.gz -
The volumes.
filesandstoragehold what people uploaded and exported: logos, images of offers, export files. The others 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 -v "$PWD":/backup alpine \ tar czf /backup/files-$(date +%F).tgz -C /data .
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 into your local instance now and then.
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, since sessions and locks are in Redis. 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). - Varnish sends every request to the host
webon port 80. Name the web serviceweb, or give it that alias. - 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.
Monitoring
- Logs: every container logs to its output.
docker compose logs -f web worker cronshows them. - Uptime:
https://ferienpass-musterstadt.de/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.- With Varnish, the proxy refuses
BAN,PURGEandPURGEKEYSrequests from outside. - 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.envandmariadb.envare readable only by you.APP_DEBUGstays0.- You update to new releases promptly: security fixes reach you only through new images.