Articles

Deploy Laravel with Octane, FrankenPHP, systemd and Traefik

Tim Kleyersburg

Tim Kleyersburg · · 6 minutes to read

I like Laravel Herd and Ploi for many things, but sometimes I want a deployment setup that is boring in a different way.

One VPS. One Linux user. One app directory. systemd keeps the processes alive. Traefik handles HTTPS. GitHub Actions can trigger deploys, but the server itself knows how to update the app.

No Kubernetes. No “platform engineering” project hiding in the corner. Just enough structure that I can understand it again in six months.

This is the setup I would use for a small Laravel app running with Laravel Octane and FrankenPHP.

All domains and paths below are examples.

#Why Octane and FrankenPHP?

Laravel normally boots the application for every request. That is fine for many projects.

Octane works differently. It boots the app once and keeps it in memory. Requests are then handled by long-running workers. FrankenPHP is one of the application servers Octane can use for this.

The upside is speed. The tradeoff is that your app is now long-running PHP. You need to be more careful with shared state, singletons, static properties and anything that accidentally remembers request-specific data.

For a boring content page, I would not reach for Octane first. For an app that already has queues, scheduled jobs and a few heavier endpoints, I like the setup.

#Install Octane

In the Laravel project:

composer require laravel/octane
php artisan octane:install --server=frankenphp

The current Laravel docs show the production command like this:

php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000

I usually bind Octane to 127.0.0.1, not 0.0.0.0, when Traefik or another reverse proxy is running on the same machine. The public internet should talk to Traefik. Traefik should talk to the app.

#Directory layout

On the server I like this shape:

/var/www/example-app
├── current app checkout
├── .env
├── deploy.sh
└── storage/

And a dedicated Linux user:

sudo adduser deploy
sudo mkdir -p /var/www/example-app
sudo chown -R deploy:deploy /var/www/example-app

You can get fancier with release directories and symlinks. That is useful for bigger setups. For a small app, a single checkout is often enough.

#The app service

Here is a simple systemd service for the HTTP app:

[Unit]
Description=Example Laravel Octane app
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/example-app
ExecStart=/usr/bin/php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --max-requests=500
Restart=always
RestartSec=5
Environment=APP_ENV=production

[Install]
WantedBy=multi-user.target

Save it as:

/etc/systemd/system/example-app.service

Then enable it:

sudo systemctl daemon-reload
sudo systemctl enable example-app
sudo systemctl start example-app

And check logs:

sudo journalctl -u example-app -f

One small warning: depending on how FrankenPHP is installed on your server, /usr/bin/php may not be the PHP runtime you actually want. On some systems FrankenPHP ships its own PHP runtime with different extensions than the system PHP.

So before writing the unit file, check both:

php -v
php -m
frankenphp php-cli -v
frankenphp php-cli -m

If your app requires an extension that only exists in FrankenPHP’s PHP runtime, use that consistently:

ExecStart=/usr/bin/frankenphp php-cli artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000

Mixing PHP runtimes is a very good way to lose an afternoon.

Ask me how I know.

#Queue worker

Most Laravel apps are not just HTTP.

If the app uses queues, run a separate service:

[Unit]
Description=Example Laravel queue worker
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/example-app
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5
Environment=APP_ENV=production

[Install]
WantedBy=multi-user.target

I like --max-time=3600 because it forces the worker to restart regularly. Long-running workers are useful, but they should not become immortal.

Enable it:

sudo systemctl enable example-app-queue
sudo systemctl start example-app-queue

#Scheduler

For the scheduler there are two common options:

  1. Use cron and call schedule:run every minute.
  2. Use schedule:work under systemd.

For this setup I prefer a service:

[Unit]
Description=Example Laravel scheduler
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/example-app
ExecStart=/usr/bin/php artisan schedule:work
Restart=always
RestartSec=5
Environment=APP_ENV=production

[Install]
WantedBy=multi-user.target

That makes all app processes visible in one place:

systemctl status example-app
systemctl status example-app-queue
systemctl status example-app-scheduler

#Traefik

Traefik only needs to route public HTTPS traffic to the local Octane port.

A minimal dynamic config can look like this:

[http.routers.example-app]
  rule = "Host(`app.example.com`)"
  entryPoints = ["websecure"]
  service = "example-app"
  [http.routers.example-app.tls]
    certResolver = "letsencrypt"

[http.services.example-app.loadBalancer]
  [[http.services.example-app.loadBalancer.servers]]
    url = "http://127.0.0.1:8000"

Again: app.example.com is an example. Do not put real internal domains into blog posts, docs, screenshots or public config snippets. It always feels harmless until it is not.

#Deploy script

The deploy script should do the same thing every time:

#!/usr/bin/env bash
set -euo pipefail

cd /var/www/example-app

git fetch origin main
git reset --hard origin/main

composer install --no-dev --prefer-dist --optimize-autoloader
npm ci
npm run build

php artisan down

php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

php artisan octane:reload || sudo systemctl restart example-app
sudo systemctl restart example-app-queue
sudo systemctl restart example-app-scheduler

php artisan up

There are many ways to improve this. Zero-downtime deployments, release folders, health checks before switching traffic, automatic rollback.

I would not start there.

I would start with a script I understand and harden it once the boring version works.

#GitHub Actions trigger

I don’t like giving GitHub Actions direct SSH keys if I can avoid it.

A cleaner version is a small deploy endpoint on the server that accepts a signed request or an OIDC-verified request and then runs the deploy script server-side.

The workflow then becomes conceptually simple:

name: deploy

on:
  workflow_run:
    workflows: ["tests"]
    types: [completed]
    branches: [main]

jobs:
  deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Trigger deployment
        run: |
          curl -X POST https://deploy.example.com/deploy \
            -H "Content-Type: application/json" \
            -d '{"repository":"owner/example-app","branch":"main"}'

The example above is intentionally incomplete. The important part is the boundary: GitHub can ask for a deploy, but the server decides whether that request is allowed.

#First deploy checklist

Before pointing DNS at the server, I would check:

php artisan migrate --force
php artisan queue:work --stop-when-empty
php artisan scout:import "App\\Models\\Post"
sudo systemctl status example-app
sudo systemctl status example-app-queue
sudo systemctl status example-app-scheduler
curl -I -H "Host: app.example.com" http://127.0.0.1

If the app has search, imports or external feeds, run those before DNS. A technically reachable app with an empty database is not ready. It is just technically reachable.

#Things I would watch

Octane changes the shape of your app. A few things are worth watching early:

  • stale config after deploy
  • request-specific data stored in singletons
  • memory growth over time
  • queue jobs using a different PHP runtime than the app
  • missing PHP extensions on the server
  • scheduler running twice after a migration
  • Traefik forwarding headers not trusted by Laravel

None of this is scary. It is just a different failure mode than PHP-FPM.

#Conclusion

This setup is not the fanciest way to run Laravel. That is the point.

One app service, one queue service, one scheduler service. Traefik in front. A deploy script that can be read without opening six dashboards. Octane and FrankenPHP for the request path, but the rest still feels like a normal Laravel app.

For small projects that need to be reliable and understandable, that is a pretty good trade.

More articles