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
Caddyfilefor 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
sudoaccess 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.
sudo adduser kibrosudo usermod -aG sudo kibrosudo usermod -aG systemd-journal kibroHarden the server
-
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.pubkibro@5.35.243.342Note, if you are on windows and
ssh-copy-idis not found, you can usessh 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 sshkibro@5.35.243.342-i/path/to/the/private/key/to/useAdditionally, you might want to setup your
.ssh/configin a way that allows you to easily connect to the host without specifying the key each time.Host your-prefered-nameHostName5.35.243.342UserkibroIdentitiesOnly yesIdentityFile/path/to/the/private/key/to/use (e.g. ~/.ssh/kibro) -
Now disable password SSH based authentication in
/etc/ssh/sshd_config:Do the next steps as the
kibrouser on your server!Terminal window PasswordAuthentication noMake 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.confTerminal window sudo systemctl restart sshdNote, on some Ubuntu images the service is called
sshnotsshd. Usesudo systemctl restart sshif it complains that the servicesshdis not found, try this. -
Set up a
ufwfirewall.We allow SSH, HTTP and HTTPS. HTTP is still needed because Caddy’s default certificate handling uses port
80for redirects and ACME HTTP challenges.Terminal window sudo ufw allow sshsudo ufw allow httpsudo ufw allow httpssudo ufw disablesudo ufw enableThe disable / enable dance at the end is needed to restart the firewall with the new rules.
-
Install and setup
CrowdSecorfail2ban:Tools like this will block spammy requests to your server and protect it from brute force attacks.
We will use
CrowdSechere, butfail2banis 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:
-
Install the CrowdSec repository
-
Install the CrowdSec engine
-
Install the correct firewall bouncer for your system
Make sure you install the correct one! E.g. check with
iptables -Vif you neediptablesornftablessupport.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 listTo 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:
sudo adduser brezelThis 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
sudouser for everything in this section.
Base packages
Install the general dependencies we need:
sudo apt-get update && sudo apt-get upgradesudo 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 qpdfInstall 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.
PHP_ZTS_STREAM=84
sudo install -d -m 0755 /etc/apt/keyringssudo curl -fsSL https://pkg.henderkes.com/api/packages/${PHP_ZTS_STREAM}/debian/repository.key -o /etc/apt/keyrings/static-php${PHP_ZTS_STREAM}.ascsudo tee /etc/apt/sources.list.d/static-php${PHP_ZTS_STREAM}.list >/dev/null <<EOFdeb [signed-by=/etc/apt/keyrings/static-php${PHP_ZTS_STREAM}.asc] https://pkg.henderkes.com/api/packages/${PHP_ZTS_STREAM}/debian php-zts mainEOFsudo apt-get updatesudo 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-zipsudo systemctl disable --now frankenphp || truesudo setcap cap_net_bind_service=+ep /usr/bin/frankenphpThe 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:
frankenphp versiongetcap /usr/bin/frankenphpYou should see a version string containing PHP 8.4.x and an entry similar to:
/usr/bin/frankenphp cap_net_bind_service=epChanging the PHP version later
If you later want to move from PHP 8.4 to PHP 8.5, the easy path is:
- Change
PHP_ZTS_STREAM=84toPHP_ZTS_STREAM=85 - Update the repository key and
sources.listentry accordingly - Run
sudo apt-get update - Upgrade
frankenphpand the installedphp-zts-*packages from that same stream - Re-check
frankenphp version - Re-apply
sudo setcap cap_net_bind_service=+ep /usr/bin/frankenphp - 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:
sudo apt remove -y imagemagick imagemagick-6-common libmagickwand-6.q16-dev libmagickcore-6.q16-dev || truesudo apt autoremove -yNow build ImageMagick 7 and install imagick:
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/imagickif [ ! -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; fiVerify the installation:
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);}EOFphp /tmp/check-imagick.phprm /tmp/check-imagick.phpYou 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:
; Enable opcache and configure for Laravelopcache.enable_cli=1opcache.enable=1opcache.memory_consumption=192opcache.interned_strings_buffer=16opcache.max_accelerated_files=10000opcache.revalidate_freq=0opcache.validate_timestamps=1opcache.max_wasted_percentage=10
; Security tweaksexpose_php=Offdisable_functions=shell_exec,system
; File size settingsupload_max_filesize=25Mpost_max_size=25M
; Memory limitmemory_limit=1G
; Execution timemax_execution_time=180
; This is needed because we hackily backported hyn to php 8. We should remove this once we migrate tenancy frameworkerror_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICTdisplay_errors=OffComposer
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.
su - brezelmkdir -p /home/brezel/.local/bincurl -sS https://getcomposer.org/installer -o composer-setup.php/usr/bin/frankenphp php-cli composer-setup.php --install-dir=/home/brezel/.local/bin --filename=composerrm composer-setup.phpchmod +x /home/brezel/.local/bin/composerThis 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:
sudo mysql_secure_installationRead 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:
mariadb -u root -pCREATE 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 = 500innodb_buffer_pool_size = 4Ginnodb_log_file_size = 512Minnodb_log_buffer_size = 16Mmax_allowed_packet = 64Mthread_pool_size = 100query_cache_size = 64MRestart the database:
sudo systemctl restart mariadbAdd DNS records
Login to your external DNS provider and add the following DNS records:
| Name | Type | Value |
|---|---|---|
| brezel.example.io | A | 5.35.243.342 |
| api.brezel.example.io | A | 5.35.243.342 |
| ws.brezel.example.io | A | 5.35.243.342 |
Prepare for an incoming Brezel
-
Create the web root directory:
Terminal window sudo mkdir -p /var/www/vhosts/api.brezel.example.io -
Set the initial ownership:
Terminal window sudo chown -R brezel:brezel /var/www/vhosts/api.brezel.example.io -
For convenience, create a symlink in the
brezelhome directory:Terminal window sudo ln -s /var/www/vhosts/api.brezel.example.io/home/brezel/brezelsudo chown -h brezel:brezel /home/brezel/brezelThis 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 - brezelcd 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.
-
Switch to the
brezeluser:Terminal window su - brezel -
Generate a new key pair:
Terminal window ssh-keygen -b 4096Store it in
~/.ssh/and leave the passphrase empty. -
Add the public key to GitLab under Settings > Repository > Deploy Keys.
Clone the repository
Move into the instance directory and clone the repository:
cd /var/www/vhosts/api.brezel.example.iogit 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:
sudo chmod +x /var/www/vhosts/api.brezel.example.io/bin/phpEnsure 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:
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:
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}/storageConfigure Brezel
Switch to the brezel user and enter the instance directory:
su - brezelcd /var/www/vhosts/api.brezel.example.ioCopy .env.example to .env:
cp .env.example .envImportant .env values
Follow the comments in the file and update all relevant values. These are the important deployment-specific ones:
APP_ENV=productionAPP_DEBUG=falseAPP_URL=https://api.brezel.example.io
TENANCY_DATABASE=brezel_metaTENANCY_USERNAME=brezelTENANCY_PASSWORD=<password>
BREZEL_JOBS_SUPERVISOR_USER=brezelBREZEL_JOBS_SUPERVISOR_COMMAND='php bakery work --tries=1'
# Broadcasting settingsBROADCAST_DRIVER=reverbBREZEL_BROTCAST_SERVER_HOST=127.0.0.1BREZEL_BROTCAST_SERVER_PORT=8086BREZEL_BROTCAST_HOST=ws.brezel.example.ioBREZEL_BROTCAST_PORT=443BREZEL_BROTCAST_APP_ID=brezelBREZEL_BROTCAST_KEY=brotcast-pusherBREZEL_BROTCAST_SECRET=an-alphanumerical-secretBREZEL_BROTCAST_SCHEME=httpsBREZEL_BROTCAST_APP_CLUSTER=mt1Create the Caddyfile
Create Caddyfile in the project root with the following contents:
{
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_serverinstead 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
80and443are 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:
composer install --no-dev --optimize-autoloaderBuild 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:
php bakery system create kabThen initialize Brezel:
php bakery init --forcephp bakery migrate --forcephp bakery applyphp bakery load --forcephp bakery make:supervisorSet up cron
If you use scheduled jobs, configure cron as the brezel user:
crontab -eAdd 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>&1Verify it was saved correctly:
crontab -lInstall 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:
[program:frankenphp]directory=/var/www/vhosts/api.brezel.example.iocommand=/usr/bin/frankenphp run --config /var/www/vhosts/api.brezel.example.io/Caddyfileautostart=trueautorestart=truestartsecs=5stopsignal=TERMstopasgroup=truekillasgroup=truestdout_logfile=/var/www/vhosts/api.brezel.example.io/storage/logs/frankenphp-stdout.logstderr_logfile=/var/www/vhosts/api.brezel.example.io/storage/logs/frankenphp-stderr.logenvironment=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
brezeluser’s home directory, where it is writable and persistent.
The bottom of the finished file should look like this:
...<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.confis generated byphp bakery make:supervisorso we include it to be able to freely regenerate it.
Register the Supervisor daemon with systemd
Create /etc/systemd/system/supervisord-brezel.service:
[Unit]Description=Run supervisord for the Brezel instanceDocumentation=https://docs.brezel.ioAfter=network.target
[Service]WorkingDirectory=/var/www/vhosts/api.brezel.example.ioExecStart=/usr/bin/supervisord -c supervisord.conf -nExecReload=/usr/bin/supervisorctl -c supervisord.conf reloadExecStop=/usr/bin/supervisorctl -c supervisord.conf stop allKillMode=mixedRestart=on-failureRestartSec=10sUser=brezelEnvironment=PATH=/var/www/vhosts/api.brezel.example.io/bin:/usr/local/bin:/usr/bin:/binUMask=0002LimitNOFILE=10000
[Install]WantedBy=multi-user.targetReload systemd and start the service:
sudo systemctl daemon-reloadsudo systemctl enable supervisord-brezelsudo systemctl start supervisord-brezelCheck the generated and manual programs:
su - brezelcd /var/www/vhosts/api.brezel.example.iosupervisorctl statusInteracting with the service
Use systemd for service-level actions:
sudo systemctl restart supervisord-brezelsudo systemctl stop supervisord-brezelsudo systemctl start supervisord-brezelsystemctl status supervisord-brezelUse 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:
sudo usermod -aG docker kibroInstallation 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:5580Result
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.