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
    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 the Host header 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_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.
  • 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.
  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 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. files and storage hold 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_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, 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 web on port 80. Name the web service web, 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, 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.

Monitoring

  • Logs: every container logs to its output. docker compose logs -f web worker cron shows them.
  • Uptime: https://ferienpass-musterstadt.de/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.
  • With Varnish, the proxy refuses BAN, PURGE and PURGEKEYS requests 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_SECRET and INTEGRATIONS_ENCRYPTION_KEY are your own, and fepli.env and mariadb.env are readable only by you.
  • APP_DEBUG stays 0.
  • You update to new releases promptly: security fixes reach you only through new images.

Was this page helpful?