fepli
Hostingenterprise

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 the Host header 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-For and X-Forwarded-Proto headers of requests from the addresses in TRUSTED_PROXIES. Its default covers the networks Docker gives a Compose project, so this setup doesn't need to set it. fepli ignores the other X-Forwarded-* headers and takes the host from Host. 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 removes X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix and Forwarded from each request, and ignores Cache-Control in requests without the purge token: a forced reload in the browser gets the cached page too. VARNISH_STORAGE is its cache size, 256 MB if you leave it out: the larger, the more pages it serves without asking fepli. It needs VARNISH_HOST=varnish:80 in fepli.env.
  • Purges. Varnish accepts BAN, PURGE and PURGEKEYS from inside the private network only, and with VARNISH_PURGE_TOKEN in varnish.env only when they carry that token. fepli sends it from fepli.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):

  1. Delete the varnish service from compose.yaml, and let the proxy depend on web instead. varnish.env is no longer needed.
  2. In the Caddyfile, send requests to fepli directly: reverse_proxy web:80.
  3. In fepli.env, set VARNISH_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
  1. pull downloads the new fepli images while the old ones keep running. If you pinned a digest, put the new one into compose.yaml first. Leave out varnish if you run without it. MariaDB, Redis and Caddy you update on your own schedule.
  2. stop takes 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.
  3. up -d starts 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 -p
    
    CREATE USER 'backup'@'localhost' IDENTIFIED BY '…';
    GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES ON fepli.* TO 'backup'@'localhost';
    

    Put the password into backup.cnf next to compose.yaml, readable only by you (chmod 600):

    backup.cnf

    [mariadb-dump]
    user=backup
    password=…
    

    Then dump, compress and encrypt. The dump reads backup.cnf from 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. files and storage hold 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_THREADS and 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_DSN and LOCK_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:, Kubernetes runAsUser or runAsNonRoot): its start script and the web server expect root.
  • Varnish sends every request to the host web on port 80. Name the web service web, or give it that alias. With a VARNISH_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 of TRUSTED_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, set args, not command: command replaces 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, set MIGRATE_ON_START=0 and run php bin/console app:migrate --with-deletes --no-backup before 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 cron shows them.
  • Uptime: https://example.fepli.eu/health answers 200 while the web container works.
  • Cron: set STATUS_HEARTBEAT_URL to an uptime monitor's heartbeat address. The cron calls it every hour; if the calls stop, the monitor raises the alarm.
  • Errors: set SENTRY_DSN to 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_HOSTS lists your domains and nothing else.
  • The proxy sets X-Forwarded-For and X-Forwarded-Proto itself instead of passing on what clients send, and keeps Host.
  • TRUSTED_PROXIES covers the networks your proxy and Varnish connect from: its default in this Compose setup, your own value elsewhere.
  • With Varnish, the proxy refuses BAN, PURGE and PURGEKEYS requests from outside, and Varnish and fepli share a VARNISH_PURGE_TOKEN of 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_SECRET and INTEGRATIONS_ENCRYPTION_KEY are your own, and fepli.env, mariadb.env and varnish.env are readable only by you.
  • ADMIN_EMAIL is an address of yours, and SENTRY_DSN, if you set it, your own project's.
  • APP_DEBUG stays 0.
  • You update to new releases promptly: security fixes reach you only through new images.

Was this page helpful?