Skip to content

Linux Server (native)

This guide explains how to deploy a Brezel instance to a modern Linux server using FrankenPHP/Caddy as the web server.

For this guide, we assume the base domain is called example.io and Brezel will be accessible under brezel.example.io. The example IP of the server is given as 5.35.243.342.

This guide uses FrankenPHP/Caddy as the web server and runs all Brezel-related processes as the brezel user. That keeps the deployment simpler and avoids the file ownership problems caused by splitting web and worker processes across different users.

Why FrankenPHP

This deployment uses:

  • one Caddyfile for SPA, API, and websocket routing
  • one FrankenPHP process for the web-facing parts
  • one application user, brezel, for web requests, workers, Reverb, and cron

This guide uses FrankenPHP in classic mode, not Laravel Octane worker mode. TLS certificates are handled automatically by Caddy when the server is publicly reachable on 80 and 443.

Prerequisites

  • A Brezel based on a recent version of brezel/api. i.e. brezel/api@4.0.0 or higher.
  • GitLab repository for that Brezel instance
  • An Ubuntu or Debian based server satisfying the requirements
  • sudo access on that machine

Basic server setup

On a brand new, empty server

If this is a new server with nothing else on it, create a new system user with sudo privileges.

This user will not run Brezel itself, but will be used for server administration. Using root directly is not recommended.

We will use the name kibro here.

Terminal window
sudo adduser kibro
sudo usermod -aG sudo kibro
sudo usermod -aG systemd-journal kibro

Harden the server

  1. Add a public key for SSH access to the new user.

    First, create a new key pair on your local machine:

    Terminal window
    ssh-keygen -C "kibro"

    Make sure to put those keys (public and private) in a safe and secure place. Possibly share them with the team using a password manager (e.g. Passbolt).

    Now, still on your local machine run this with the correct values to copy the public key to the server so you can log in with the private key:

    Terminal window
    ssh-copy-id -i /path/to/the/public/key/to/use.pub kibro@5.35.243.342

    Note, if you are on windows and ssh-copy-id is not found, you can use

    ssh kibro@5.35.243.342 "mkdir .ssh; touch .ssh/authorized_keys"
    type /path/to/the/public/key/to/use.pub | ssh kibro@5.35.243.342 "cat >> .ssh/authorized_keys"

    Finally, validate that you can log in with the new user:

    Terminal window
    ssh kibro@5.35.243.342 -i /path/to/the/private/key/to/use

    Additionally, you might want to setup your .ssh/config in a way that allows you to easily connect to the host without specifying the key each time.

    Host your-prefered-name
    HostName 5.35.243.342
    User kibro
    IdentitiesOnly yes
    IdentityFile /path/to/the/private/key/to/use (e.g. ~/.ssh/kibro)
  2. Now disable password SSH based authentication in /etc/ssh/sshd_config:

    Do the next steps as the kibro user on your server!

    Terminal window
    PasswordAuthentication no

    Make sure no file in /etc/ssh/sshd_config.d/ overrides this setting.

    e.g. on Hetzner servers, you need to delete the file /etc/ssh/sshd_config.d/50-cloud-init.conf

    Terminal window
    sudo systemctl restart sshd

    Note, on some Ubuntu images the service is called ssh not sshd. Use sudo systemctl restart ssh if it complains that the service sshd is not found, try this.

  3. Set up a ufw firewall.

    We allow SSH, HTTP and HTTPS. HTTP is still needed because Caddy’s default certificate handling uses port 80 for redirects and ACME HTTP challenges.

    Terminal window
    sudo ufw allow ssh
    sudo ufw allow http
    sudo ufw allow https
    sudo ufw disable
    sudo ufw enable

    The disable / enable dance at the end is needed to restart the firewall with the new rules.

  4. Install and setup CrowdSec or fail2ban:

    Tools like this will block spammy requests to your server and protect it from brute force attacks.

    We will use CrowdSec here, but fail2ban is a good alternative.

    Install it using the latest instructions from the CrowdSec documentation. You most probably need to register the repository of them, see here: Add CrowdSec repository.

    Do the following:

    1. Install the CrowdSec repository

    2. Install the CrowdSec engine

    3. Install the correct firewall bouncer for your system

    Make sure you install the correct one! E.g. check with iptables -V if you need iptables or nftables support.

    Make sure to check the official documentation for the most up-to-date installation instructions.

    Now you can validate that the bouncer and the engine are running:

    Terminal window
    sudo cscli bouncers list

    To see more detailed infos about what CroudSec is doing, you can check the metrics:

    Terminal window
    sudo cscli metrics

On an already setup server

You fall into this category if you already have a user with sudo access on the server.

Now create the dedicated user for the Brezel instance:

Terminal window
sudo adduser brezel

This user will own the application files and run:

  • FrankenPHP/Caddy
  • Supervisor worker processes
  • Laravel Reverb/Brotcast
  • Cron jobs
  • one-off php bakery ... commands via the Pipeline or SSH

Install needed dependencies

Use your sudo user for everything in this section.

Base packages

Install the general dependencies we need:

Terminal window
sudo apt-get update && sudo apt-get upgrade
sudo apt-get install -y curl git unzip ghostscript acl supervisor mariadb-server build-essential libgs-dev libjpeg-dev libpng-dev libtiff-dev libwebp-dev wget qpdf

Install FrankenPHP and PHP extensions

For production, we recommend the Debian packages for FrankenPHP and the matching ZTS PHP extensions.

This guide intentionally installs the PHP 8.4 equivalent FrankenPHP runtime by using the 84 package stream. This is the reason we use the pkg.henderkes.com repository instead of the official frankenphp package as that one bundles a bunch of extensions (good), but only ships with the latest php version (bad, because it might be too new for the currently recommended Brezel version).

The important rule is: frankenphp and all php-zts-* extensions must come from the same stream / PHP version.

Terminal window
PHP_ZTS_STREAM=84
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://pkg.henderkes.com/api/packages/${PHP_ZTS_STREAM}/debian/repository.key -o /etc/apt/keyrings/static-php${PHP_ZTS_STREAM}.asc
sudo tee /etc/apt/sources.list.d/static-php${PHP_ZTS_STREAM}.list >/dev/null <<EOF
deb [signed-by=/etc/apt/keyrings/static-php${PHP_ZTS_STREAM}.asc] https://pkg.henderkes.com/api/packages/${PHP_ZTS_STREAM}/debian php-zts main
EOF
sudo apt-get update
sudo apt-get install -y frankenphp pie-zts php-zts-bcmath php-zts-curl php-zts-exif php-zts-gd php-zts-gmp php-zts-intl php-zts-mbstring php-zts-mysql php-zts-opcache php-zts-sockets php-zts-xml php-zts-zip
sudo systemctl disable --now frankenphp || true
sudo setcap cap_net_bind_service=+ep /usr/bin/frankenphp

The Debian package may ship a frankenphp systemd unit. We disable it here because this guide manages FrankenPHP via Supervisor instead.

The setcap command allows frankenphp to bind to ports 80 and 443 even though it will later run as the unprivileged brezel user.

After installation, verify that the installed FrankenPHP runtime is actually on PHP 8.4:

Terminal window
frankenphp version
getcap /usr/bin/frankenphp

You should see a version string containing PHP 8.4.x and an entry similar to:

Terminal window
/usr/bin/frankenphp cap_net_bind_service=ep

Changing the PHP version later

If you later want to move from PHP 8.4 to PHP 8.5, the easy path is:

  1. Change PHP_ZTS_STREAM=84 to PHP_ZTS_STREAM=85
  2. Update the repository key and sources.list entry accordingly
  3. Run sudo apt-get update
  4. Upgrade frankenphp and the installed php-zts-* packages from that same stream
  5. Re-check frankenphp version
  6. Re-apply sudo setcap cap_net_bind_service=+ep /usr/bin/frankenphp
  7. Restart supervisord-brezel

Treat that as a normal PHP runtime upgrade and test it the same way you would have tested a move from php8.4 to php8.5 before.

Imagick

ImageMagick is needed to generate thumbnails. Because recent Brezel installations benefit from ImageMagick 7.x, we build it from source and then install the ZTS imagick extension with pie-zts.

First, remove conflicting old ImageMagick 6 packages if they are installed:

Terminal window
sudo apt remove -y imagemagick imagemagick-6-common libmagickwand-6.q16-dev libmagickcore-6.q16-dev || true
sudo apt autoremove -y

Now build ImageMagick 7 and install imagick:

Terminal window
sudo bash -lc 'cd /usr/src && wget https://github.com/ImageMagick/ImageMagick/archive/refs/heads/main.tar.gz -O ImageMagick.tar.gz && tar xvf ImageMagick.tar.gz && cd ImageMagick-main && ./configure && make -j$(nproc) && make install && ldconfig'
sudo env PKG_CONFIG_PATH=/usr/local/lib/pkgconfig pie-zts install imagick/imagick
if [ ! -f /etc/php-zts/conf.d/20-imagick.ini ]; then echo "extension=imagick.so" | sudo tee /etc/php-zts/conf.d/20-imagick.ini >/dev/null; fi

Verify the installation:

Terminal window
tee /tmp/check-imagick.php >/dev/null <<'EOF'
<?php
$loaded = extension_loaded('imagick');
var_dump($loaded);
if ($loaded) {
var_dump(count(Imagick::queryFormats('PDF')) > 0);
}
EOF
php /tmp/check-imagick.php
rm /tmp/check-imagick.php

You should see bool(true) twice. The first confirms that the extension is loaded, the second confirms that PDF support is available.

PHP configuration

Adjust /etc/php-zts/php.ini for production.

Add or update the following values:

/etc/php-zts/php.ini
; Enable opcache and configure for Laravel
opcache.enable_cli=1
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0
opcache.validate_timestamps=1
opcache.max_wasted_percentage=10
; Security tweaks
expose_php=Off
disable_functions=shell_exec,system
; File size settings
upload_max_filesize=25M
post_max_size=25M
; Memory limit
memory_limit=1G
; Execution time
max_execution_time=180
; This is needed because we hackily backported hyn to php 8. We should remove this once we migrate tenancy framework
error_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICT
display_errors=Off

Composer

Switch to the brezel user and install Composer via the official installer.

At this point, the repository has not been cloned yet, so the repo-shipped bin/php shim is not available yet. That is why we call FrankenPHP directly for the installer step.

Terminal window
su - brezel
mkdir -p /home/brezel/.local/bin
curl -sS https://getcomposer.org/installer -o composer-setup.php
/usr/bin/frankenphp php-cli composer-setup.php --install-dir=/home/brezel/.local/bin --filename=composer
rm composer-setup.php
chmod +x /home/brezel/.local/bin/composer

This keeps Composer registered only for the brezel user instead of installing it globally.

We create the repo-shipped php shim a bit later, after the first repository clone. Once that shim is in place and the relevant PATH entries are set, composer will work normally for the brezel user without needing to use the frankenphp php-cli prefix.

Database

Run the MariaDB hardening step:

Terminal window
sudo mysql_secure_installation

Read the prompts carefully. You should remove the test database, disallow remote root login, and set a secure root password.

You can create a privileged brezel database user now:

Terminal window
mariadb -u root -p
CREATE USER 'brezel'@'localhost' IDENTIFIED BY '<your secure password>';
GRANT ALL ON *.* TO 'brezel'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;

Server performance tweaks

Database tuning

Adjust your database configuration in /etc/mysql/my.cnf by adding this block at the bottom:

[mysqld]
max_connections = 500
innodb_buffer_pool_size = 4G
innodb_log_file_size = 512M
innodb_log_buffer_size = 16M
max_allowed_packet = 64M
thread_pool_size = 100
query_cache_size = 64M

Restart the database:

Terminal window
sudo systemctl restart mariadb

Add DNS records

Login to your external DNS provider and add the following DNS records:

NameTypeValue
brezel.example.ioA5.35.243.342
api.brezel.example.ioA5.35.243.342
ws.brezel.example.ioA5.35.243.342

Prepare for an incoming Brezel

  1. Create the web root directory:

    Terminal window
    sudo mkdir -p /var/www/vhosts/api.brezel.example.io
  2. Set the initial ownership:

    Terminal window
    sudo chown -R brezel:brezel /var/www/vhosts/api.brezel.example.io
  3. For convenience, create a symlink in the brezel home directory:

    Terminal window
    sudo ln -s /var/www/vhosts/api.brezel.example.io /home/brezel/brezel
    sudo chown -h brezel:brezel /home/brezel/brezel

    This helps the maintenance process as you can just do to get to the instance directory without needing to type much.

    Terminal window
    ssh <your server>
    sh - brezel
    cd brezel

Get your Brezel onto the server

Connect to GitLab

This creates SSH keys for the brezel user so the server can clone the repository via deploy keys.

  1. Switch to the brezel user:

    Terminal window
    su - brezel
  2. Generate a new key pair:

    Terminal window
    ssh-keygen -b 4096

    Store it in ~/.ssh/ and leave the passphrase empty.

  3. Add the public key to GitLab under Settings > Repository > Deploy Keys.

Clone the repository

Move into the instance directory and clone the repository:

Terminal window
cd /var/www/vhosts/api.brezel.example.io
git clone --branch main git@gitlab.kiwis-and-brownies.de:kibro/basedonbrezel/example.git .

Use the repo-shipped php shim

The Brezel repository you are about to clone contains a bin/ directory with shims for the php command which just uses frankenphp php-cli under the hood to provide one consistent php environment for the web server and cli usage.

Many existing Brezel commands, deploy scripts, and Composer workflows assume a php binary exists. We keep that convention by putting the cloned repository’s bin/ directory into the brezel user’s PATH.

This way, only the brezel user gets the FrankenPHP-backed php command in production. Other users can keep using their own system PHP installation untouched.

Ensure the repo-shipped bin/php is executable:

Terminal window
sudo chmod +x /var/www/vhosts/api.brezel.example.io/bin/php

Ensure that the cloned repository’s bin/ directory comes first in the brezel user’s PATH. For interactive shells, add this line to /home/brezel/.profile:

/home/brezel/.profile
export PATH="$HOME/brezel/bin:$HOME/.local/bin:$PATH"

We also set this PATH explicitly later for systemd and cron so Supervisor jobs and scheduled tasks use the same repo shim reliably.

Configure file system permissions

The whole point of this deployment is that the same user owns and writes the relevant application files. So we intentionally keep the permissions model simple.

Switch back to your sudo user and run:

Terminal window
APP_PATH="/var/www/vhosts/api.brezel.example.io"
sudo chown -R brezel:brezel ${APP_PATH}
sudo find ${APP_PATH} -type d -exec chmod 2775 {} \;
sudo find ${APP_PATH} -type f -exec chmod ug+rw,o-rwx {} \;
sudo chmod -R 2775 ${APP_PATH}/storage

Configure Brezel

Switch to the brezel user and enter the instance directory:

Terminal window
su - brezel
cd /var/www/vhosts/api.brezel.example.io

Copy .env.example to .env:

Terminal window
cp .env.example .env

Important .env values

Follow the comments in the file and update all relevant values. These are the important deployment-specific ones:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://api.brezel.example.io
TENANCY_DATABASE=brezel_meta
TENANCY_USERNAME=brezel
TENANCY_PASSWORD=<password>
BREZEL_JOBS_SUPERVISOR_USER=brezel
BREZEL_JOBS_SUPERVISOR_COMMAND='php bakery work --tries=1'
# Broadcasting settings
BROADCAST_DRIVER=reverb
BREZEL_BROTCAST_SERVER_HOST=127.0.0.1
BREZEL_BROTCAST_SERVER_PORT=8086
BREZEL_BROTCAST_HOST=ws.brezel.example.io
BREZEL_BROTCAST_PORT=443
BREZEL_BROTCAST_APP_ID=brezel
BREZEL_BROTCAST_KEY=brotcast-pusher
BREZEL_BROTCAST_SECRET=an-alphanumerical-secret
BREZEL_BROTCAST_SCHEME=https
BREZEL_BROTCAST_APP_CLUSTER=mt1

Create the Caddyfile

Create Caddyfile in the project root with the following contents:

/var/www/vhosts/api.brezel.example.io/Caddyfile
{
admin off
# Your contact email used to issue TLS Certificates. Optional, but recommended to get notified about certificate issues.
email you@example.io
servers {
enable_full_duplex
}
frankenphp {
num_threads 8
max_threads auto
max_requests 500
}
log {
output file /var/www/vhosts/api.brezel.example.io/storage/logs/caddy.log
format console
level WARN
}
}
brezel.example.io {
root * /var/www/vhosts/api.brezel.example.io/dist
encode zstd br gzip
try_files {path} /index.html
file_server
}
api.brezel.example.io {
root * /var/www/vhosts/api.brezel.example.io/public
encode zstd br gzip
request_body {
max_size 25MB
}
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
-Server
}
php_server {
root /var/www/vhosts/api.brezel.example.io/public
try_files {path} index.php
}
}
ws.brezel.example.io {
encode zstd br gzip
reverse_proxy 127.0.0.1:8086
}

Notes on this Caddyfile

  • The SPA is served directly from dist/.
  • The API uses FrankenPHP’s php_server instead of PHP-FPM.
  • The websocket host proxies directly to Laravel Reverb on 127.0.0.1:8086.
  • TLS certificates for all three hosts are obtained automatically by Caddy as long as DNS already points to the server and ports 80 and 443 are reachable.

If you cannot use automatic HTTPS, replace each site block’s automatic TLS with explicit certificates, for example:

brezel.example.io {
tls /etc/ssl/mycerts/brezel.fullchain.pem /etc/ssl/mycerts/brezel.privkey.pem
# ...
}

Get the application ready

Install the PHP dependencies:

Terminal window
composer install --no-dev --optimize-autoloader

Build or deploy the SPA so that the compiled frontend exists in dist/. If you use the GitLab pipeline for that, follow the Pipeline guide.

Create your system

Create your system:

Terminal window
php bakery system create kab

Then initialize Brezel:

Terminal window
php bakery init --force
php bakery migrate --force
php bakery apply
php bakery load --force
php bakery make:supervisor

Set up cron

If you use scheduled jobs, configure cron as the brezel user:

Terminal window
crontab -e

Add the following line:

PATH=/var/www/vhosts/api.brezel.example.io/bin:/usr/local/bin:/usr/bin:/bin
* * * * * cd /var/www/vhosts/api.brezel.example.io && php bakery schedule >> /dev/null 2>&1

Verify it was saved correctly:

Terminal window
crontab -l

Install and configure Supervisor

Supervisor will manage:

  • the FrankenPHP web server process
  • the generated Brezel worker programs
  • Laravel Reverb/Brotcast if Brezel generates that program for you

Setup supervisord.conf

Copy supervisord.conf.example to supervisord.conf.

Now append the FrankenPHP program block to the bottom of that copied supervisord.conf file:

Append to supervisord.conf
[program:frankenphp]
directory=/var/www/vhosts/api.brezel.example.io
command=/usr/bin/frankenphp run --config /var/www/vhosts/api.brezel.example.io/Caddyfile
autostart=true
autorestart=true
startsecs=5
stopsignal=TERM
stopasgroup=true
killasgroup=true
stdout_logfile=/var/www/vhosts/api.brezel.example.io/storage/logs/frankenphp-stdout.log
stderr_logfile=/var/www/vhosts/api.brezel.example.io/storage/logs/frankenphp-stderr.log
environment=HOME="/home/brezel",XDG_CONFIG_HOME="/home/brezel/.config",XDG_DATA_HOME="/home/brezel/.local/share"

Those environment variables ensure that Caddy stores certificates and runtime data in the brezel user’s home directory, where it is writable and persistent.

The bottom of the finished file should look like this:

Excerpt from supervisord.conf
...<the rest of the file>...
[include]
files = storage/workers.supervisord.conf ; Separates worker configurations for better organization and maintainability
[program:frankenphp]
directory=/var/www/vhosts/api.brezel.example.io
...<the rest of the frankenphp program block>...

storage/workers.supervisord.conf is generated by php bakery make:supervisor so we include it to be able to freely regenerate it.

Register the Supervisor daemon with systemd

Create /etc/systemd/system/supervisord-brezel.service:

/etc/systemd/system/supervisord-brezel.service
[Unit]
Description=Run supervisord for the Brezel instance
Documentation=https://docs.brezel.io
After=network.target
[Service]
WorkingDirectory=/var/www/vhosts/api.brezel.example.io
ExecStart=/usr/bin/supervisord -c supervisord.conf -n
ExecReload=/usr/bin/supervisorctl -c supervisord.conf reload
ExecStop=/usr/bin/supervisorctl -c supervisord.conf stop all
KillMode=mixed
Restart=on-failure
RestartSec=10s
User=brezel
Environment=PATH=/var/www/vhosts/api.brezel.example.io/bin:/usr/local/bin:/usr/bin:/bin
UMask=0002
LimitNOFILE=10000
[Install]
WantedBy=multi-user.target

Reload systemd and start the service:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable supervisord-brezel
sudo systemctl start supervisord-brezel

Check the generated and manual programs:

Terminal window
su - brezel
cd /var/www/vhosts/api.brezel.example.io
supervisorctl status

Interacting with the service

Use systemd for service-level actions:

Terminal window
sudo systemctl restart supervisord-brezel
sudo systemctl stop supervisord-brezel
sudo systemctl start supervisord-brezel
systemctl status supervisord-brezel

Use supervisorctl status only to inspect the process state as the brezel user.

Optional: export service

If your Brezel creates PDFs or fills .docx templates, you still need the export service.

If you choose the Docker variant, add your sudo user to the docker group, not the brezel user:

Terminal window
sudo usermod -aG docker kibro

Installation instructions and a helper CLI can be found here: kibro/brezel/export/export-installer.

Set this in your .env once the service is running:

BREZEL_EXPORT_URL=http://127.0.0.1:5580

Result

At this point:

  • the SPA is served directly by Caddy from dist/
  • the Laravel API is served by FrankenPHP from public/
  • the websocket endpoint is proxied by Caddy to Reverb
  • all relevant application processes run as brezel
  • Caddy handles HTTPS certificates automatically

That means the old www-data vs brezel ownership split is gone, and with it most of the cache and storage permission trouble.