Skip to content

Running a Laravel App on Trellis Without WordPress: Four Deploy Blockers and How to Skip Them

• By Jasper Frumau DevOps

Trellis is built to provision and deploy WordPress. It installs WP-CLI, runs wp core install, writes a .env full of WordPress salts, and checks wp core is-installed before every release goes live. So when we wanted to put a Laravel + Vue app on the same Hetzner server that already hosts our WordPress sites, the first deploy attempt had an obvious problem: it would fail on the very first WordPress check, in an app that has never heard of WordPress.

The good news is that Trellis is more flexible than it looks. Most of the site model can be overridden per site, and the few things that cannot are easy to route around. This post covers what we changed, what broke, and what we have and have not verified so far.

Quick Summary: A Laravel app can run as an entry in Trellis’s wordpress_sites. Override public_path, set site_install: false, replace the shared directories, and make the deploy hook lists site-aware so the WordPress-only hooks are skipped. Add Redis, any missing PHP extensions, a queue worker and a scheduler cron yourself. It works on our Lima dev VM and has now been deployed to the production server, where the first deploy needed one manual step: seeding the database once.

What’s Covered

Why Put Laravel on Trellis at All

We already run Trellis for every WordPress site we host, as part of our managed WordPress hosting. Provisioning, Nginx, PHP-FPM, MariaDB, Redis, Let’s Encrypt and Ansible-based deploys are all in place and understood. A second server for one Laravel app means a second thing to patch, monitor and pay for. Reusing the existing stack is cheaper, and a Laravel app has the same basic needs as a WordPress site: PHP-FPM behind Nginx, a database, TLS and a repeatable deploy.

There is a real cost. trellis provision renders Nginx for every site on the server and reloads it once, so a bad Laravel vhost can take down the WordPress sites with it. We test on the Lima dev VM first and scope provisioning with tags rather than running a full provision against production.

The Four Blockers That Fail a Plain Setup

If you add the app as a normal wordpress_sites entry and change nothing else, these are the places it breaks or misbehaves:

WhereWhat happensFix
roles/deploy/hooks/finalize-before.ymlRuns wp core is-installed in the release directory. In a Laravel app wp errors, the task has failed_when on stderr, and the deploy fails.Override deploy_finalize_before and deploy_finalize_after for this site.
roles/wordpress-installRuns wp core install on provision for every site where site_install is truthy, which is the default.Set site_install: false.
wordpress-install/tasks/dotenv.ymlWrites a Trellis-generated .env with WordPress-shaped keys over the release’s .env.Put the Laravel keys in the site’s env: in the vault. The extra WP keys are harmless.
roles/wordpress-setup/tasks/main.ymlAdds a wp cron event run --due-now cron for the site. On Laravel it fails silently.Harmless noise. It cannot be disabled per site because cron_enabled is global.

Only the first one is a hard blocker. The rest are things to know about.

The Site Entry: Per-Site Overrides

We checked each variable against the Trellis roles before relying on it. public_path, nginx_wordpress_site_conf, project_shared_children and site_install are all per-site. That covers the web root, the vhost template, the shared directories and the WordPress install step. This is the entry in group_vars/<env>/wordpress_sites.yml:

For the developers: the full wordpress_sites entry

The complete site entry, with each override commented.

wordpress_sites:
  laravel-app.example:
    site_hosts:
      - canonical: laravel-app.example
        redirects:
          - www.laravel-app.example
    local_path: ../laravel-app
    repo: git@github.com:your-org/laravel-app.git
    branch: main

    public_path: public               # default is 'web'
    site_install: false               # skip `wp core install`
    update_wp_theme_paths: false
    update_db_on_deploy: false
    flush_rewrite_rules_on_deploy: false

    xmlrpc:
      enabled: false
    ssl:
      enabled: true
      provider: letsencrypt
    object_cache:                     # installs Redis; Laravel cache tags need it
      enabled: true
      provider: redis
      database: 0

    project_shared_children:          # replaces the default (uploads)
      - path: storage
        src: storage

Two things to be careful about. update_wp_theme_paths, update_db_on_deploy and flush_rewrite_rules_on_deploy only guard some of the WordPress tasks in finalize-before. The wp core is-installed check still runs first, which is why the hook override below is needed. And a few variables people expect do not exist: there is no per-site php_fpm_pool, and the database and user come from the site’s own db_name, db_user and db_password, not from vault_mysql_users.

Nginx and Shared Directories

The vhost needs less than you would think

Our first draft was a from-scratch Laravel vhost template. That was unnecessary. Trellis’s wordpress-site.conf.j2 is block-based and already does what Laravel needs: the root points at current/<public_path>, try_files falls back to /index.php, PHP goes to the shared FPM socket, and files like composer.json and package.json are blocked. A from-scratch template would also duplicate the ACME challenge and HTTP-to-HTTPS logic Trellis already generates. If you do need an override, extend the base rather than replacing it, and start with no override at all.

One thing to check in the rendered vhost is the FastCGI cache. If fastcgi_cache_enabled is on, Laravel pages can be cached keyed on WordPress cookies. Add Laravel’s session cookies (laravel_session, XSRF-TOKEN) to the site’s skip-cache cookies, or turn the cache off for that site. The same class of problem broke our Contact Form 7 CAPTCHA: a cached page serving every visitor the same dynamic content.

Share only what the app writes at runtime

project_shared_children on the site replaces the default list, which is web/app/uploads. Trellis symlinks the release path to shared/<src> and does not copy existing release content into it, unlike Deployer’s shared_dirs. So only share directories the app writes at runtime. We share storage/ and a few runtime-written directories under public/. We deliberately do not share bootstrap/cache, because per-release is correct with config:cache and route:cache. We also left out two public/ directories that the repository tracks files in, since an empty shared directory would hide them.

Laravel returns a 500 if storage/framework/cache/data, sessions, views or storage/logs are missing, so the build hook creates them in shared/storage on deploy.

Making the Deploy Hooks Site-Aware

Trellis hook lists such as deploy_finalize_before are global variables, but they are evaluated per site during a deploy, so a Jinja conditional on site works. In group_vars/all/main.yml:

For the developers: the site-aware hook lists

The Jinja conditionals that route this one site to its own hooks.

deploy_build_after: >-
  {{ [playbook_dir + '/roles/deploy/hooks/build-after.yml']
     + ([playbook_dir + '/deploy-hooks/sites/laravel-build-after.yml'] if site == 'laravel-app.example'
        else [playbook_dir + '/deploy-hooks/build-after.yml']) }}
deploy_finalize_before: >-
  {{ [] if site == 'laravel-app.example'
     else [playbook_dir + '/roles/deploy/hooks/finalize-before.yml'] }}
deploy_finalize_after: >-
  {{ [playbook_dir + '/deploy-hooks/sites/laravel-finalize-after.yml'] if site == 'laravel-app.example'
     else [playbook_dir + '/roles/deploy/hooks/finalize-after.yml'] }}

The core build-after hook stays in the list because its composer install is exactly what Laravel needs. The Laravel-specific build hook seeds shared/storage, then builds the frontend assets. Our production server has no Node, and Trellis is designed to build assets on the machine running the deploy, so the hook runs npm ci and npm run build locally with delegate_to: localhost and uploads public/build to the new release with synchronize. That directory is git-ignored, so it never arrives through the repository.

For the developers: the build-after hook

Seed the shared storage tree, build locally, then rsync the compiled assets into the release.

- name: Seed shared storage directories
  file:
    path: "{{ project_root }}/shared/storage/{{ item }}"
    state: directory
    mode: '0775'
  loop:
    - app/public
    - framework/cache/data
    - framework/sessions
    - framework/views
    - logs

- name: Install NPM dependencies (local)
  command: npm ci
  delegate_to: localhost
  args:
    chdir: "{{ project_local_path }}"

- name: Build frontend assets (local)
  command: npm run build
  delegate_to: localhost
  args:
    chdir: "{{ project_local_path }}"

- name: Copy compiled assets
  synchronize:
    src: "{{ project_local_path }}/public/build"
    dest: "{{ deploy_helper.new_release_path }}/public"
    delete: yes
    group: no
    owner: no

The finalize hook runs after the current symlink flips, so artisan sees the shared .env and storage. Ours grew well beyond three commands once we ran it against the app’s real database layout:

For the developers: the finalize-after tasks

Storage link, guarded migrations for three databases, a one-time schema import, caches, and worker restarts. Each guard exists because of a failure we hit.

- name: Ensure storage symlink
  command: php artisan storage:link --force
  args:
    chdir: "{{ deploy_helper.current_path }}"

- name: Run database migrations (main)
  command: php artisan migrate --force
  args:
    chdir: "{{ deploy_helper.current_path }}"

- name: Check main database has a migrations table
  command: php artisan migrate:status --database=mysql
  args:
    chdir: "{{ deploy_helper.current_path }}"
  register: app_main_status
  failed_when: false
  changed_when: false

- name: Run database migrations (main, subfolder)
  command: php artisan migrate --force --database=mysql --path=database/migrations/mysql
  args:
    chdir: "{{ deploy_helper.current_path }}"
  when: app_main_status.rc == 0

- name: Check backup database has a migrations table
  command: php artisan migrate:status --database=mysql_backup --path=database/migrations/backup
  args:
    chdir: "{{ deploy_helper.current_path }}"
  register: app_backup_status
  failed_when: false
  changed_when: false

- name: Run database migrations (backup)
  command: php artisan migrate --force --database=mysql_backup --path=database/migrations/backup
  args:
    chdir: "{{ deploy_helper.current_path }}"
  when: app_backup_status.rc == 0

- name: Cache config, routes and views
  shell: php artisan config:cache && php artisan route:cache && php artisan view:cache
  args:
    chdir: "{{ deploy_helper.current_path }}"

- name: Restart queue workers
  command: php artisan queue:restart
  args:
    chdir: "{{ deploy_helper.current_path }}"

- name: Restart Horizon
  command: php artisan horizon:terminate
  args:
    chdir: "{{ deploy_helper.current_path }}"

The guards matter. migrate:status exits non-zero when a database has no migrations table, and running migrate against such a database makes Laravel load the app’s schema dump over it and fail. So the extra migration runs are skipped on a clean database, which you bootstrap once by hand. The app also keeps migrations in more than one folder, so a plain migrate never reads the subfolders, and running the default-path migrations against the backup database would alter a users table that only exists in the main one. One more database has no migration files at all. Its tables come from a schema dump, and we import that only while the projects table is missing, because the dump starts with DROP TABLE IF EXISTS. The import task is a no-op on every deploy after the first. It reads the database credentials from the release’s .env at run time, so no password appears in the playbook.

A few lessons from the first draft of these hooks:

  • Use shell, not command, for anything with &&.
  • Do not assume Node exists on the server. Build where the deploy runs and upload the output.
  • Run artisan commands that need the .env or the database after finalize, not during build.
  • Do not chown or chmod storage recursively on every deploy. Trellis deploys as web, which is also the FPM user, so ownership is already right.
  • Migrations in finalize-after run once traffic is already on the new code. For simple apps that is fine. Otherwise write backwards-compatible migrations.

First deploy only: seed the database by hand

The hooks run migrations on every deploy, but they do not seed. On the first production deploy the tables existed and the app still had no roles, products, company record or user. We ran the seeders once over SSH, from the release directory, and left them out of the deploy hooks on purpose. Seeders are not idempotent in general, and one that inserts users or starter data on every deploy would duplicate or overwrite real records.

For the developers: the one-time seed command

Run once on the server after the first successful deploy, as the deploy user. Never again after that.

ssh web@your-server
cd /srv/www/laravel-app.example/current
php artisan db:seed --force

Two follow-ups are easy to forget. First, the seeder creates a user with a placeholder password, the kind of trivially guessable default Laravel seeders commonly use. Change it for every seeded account right after seeding, before anyone else can reach the login page. Until you do, the production site has an account anyone could guess into. Second, --force is needed because Laravel refuses to seed in production without it. That refusal is a useful safety, so do not add the flag to an automated hook.

Environment variables and the database

Trellis creates the database and user from the site’s db_name, db_user and db_password, so only the password goes in the vault. Laravel’s keys go in the same site’s env: block in vault.yml, and Trellis merges them into the .env it writes. That file is encrypted with Ansible Vault, one per environment, and none of the values belong in a blog post, a repository history or a chat. Treat the list below as the names of what we had to set, not as a template to paste:

ConstantWhy it needed setting
APP_KEYMust be a real base64: key of the right length, generated with php artisan key:generate --show. A placeholder fails with Unsupported cipher or incorrect key length. Production gets its own key, never the development one.
APP_ENV, APP_DEBUG, APP_URLProduction values differ from development. A wrong APP_URL scheme makes Laravel build http:// links that browsers block as mixed content.
App domain settingOur routes are bound to a configured domain. Left at its default, every route returns a 404 even though Laravel boots.
CACHE_DRIVER, REDIS_CLIENTCache tags need Redis, because the file store does not support them. The client has to match what is installed: the Composer package, not a PHP extension the server lacks.
DB_* for each connectionOne set per database connection. The values must match the db_name, db_user and db_password Trellis derives.
Mail host, credentials and senderProduction uses a real SMTP provider, not the local mail catcher.
Notification email addressesWhere the app sends its own alerts. Easy to leave pointing at a development address.

Edit the vault with trellis vault edit -f group_vars/production/vault.yml. Our vault files rewrite almost entirely on each edit, because Ansible Vault re-encrypts the whole file, so a diff of hundreds of changed lines for a small edit is normal and tells you nothing about what changed. Review the decrypted values before encrypting, not the diff afterwards.

If your app uses extra database connections, Trellis will not create those databases for you. We wrote a small playbook that creates them and grants the site’s database user on them.

Queue Worker and Scheduler

Trellis has no hook for either, so we added them in a separate playbook. The queue worker is a systemd unit running php artisan horizon as the web user, not www-data, so it can read the shared .env and storage. The finalize hook runs horizon:terminate and systemd restarts Horizon on the new release. Without it, queued jobs pile up in Redis and never run. The scheduler is a cron entry running schedule:run every minute, in every environment except development, where scheduled jobs such as certificate renewal should not touch real services.

Also watch PHP extensions. Trellis runs composer install for every site on provision, and a missing ext-* from the app’s composer.lock fails the entire provision run, WordPress sites included. We found one missing (soap) and added it to php_extensions_custom next to gd. Check every ext- key in composer.lock against php -m on the server before the first provision.

What Is Verified and What Is Not

This is now deployed to our production server, so here is exactly where each piece stands:

StatusItem
Verified on the Lima dev VMpublic_path, site_install: false, Redis via object_cache, the extra PHP extension, the wildcard server_name for subdomains, and the existing WordPress sites staying unaffected.
Done on productionThe site-aware hook lists, the build hook with local asset builds, the guarded migrations, and the first deploy. The first deploy needed one manual step: a one-time db:seed and a password change for the seeded accounts.
Still to confirmWhether the wp cron job Trellis adds for the site stays harmless over time, and how a second and third deploy behave once real data exists.
Not yet doneA wildcard TLS certificate for the app’s subdomains. The per-site ssl config only certifies the listed hosts over HTTP-01, so that needs a separate DNS-01 Let’s Encrypt setup.

Development still never exercises the deploy hooks, because the Lima VM mounts the local checkout and trellis deploy is not used there. That is why the first real run of the hooks was also the first time the seeding gap showed up. If you copy this setup, put a seed-and-set-passwords step on your first-deploy checklist rather than discovering it afterwards.

When This Is the Wrong Approach

OptionWhen it fits
Laravel as a wordpress_sites entry (this post)One app, you already run Trellis, and you accept the opt-outs above.
A dedicated laravel-setup roleLaravel stays on the server long-term. No WordPress opt-outs and no cross-site risk, but more Ansible to write and maintain.
A separate VPS with Deployer, Forge or PloiYou want real isolation, or RAM is tight. A small monthly cost, and a failed Laravel deploy can no longer touch WordPress.

RAM is the thing to check first. If your WordPress cron jobs already use a few gigabytes per run (see how we sized PHP-FPM children on Trellis), adding a Laravel app plus a Horizon worker to the same server is not free. If the dev VM test turns out brittle, we would move to the dedicated role or a separate server rather than keep adding exceptions.

Frequently Asked Questions

  • Can Trellis host a Laravel app? Yes, as an entry in wordpress_sites with per-site overrides. Set public_path to public, set site_install to false, replace the shared directories, and make the deploy hook lists skip the WordPress-only hooks for that site.
  • Why does a Laravel deploy on Trellis fail with a wp error? The core finalize-before hook runs wp core is-installed in the release directory. In a Laravel app WP-CLI errors, and the task fails on any stderr output, so the whole deploy fails. Override deploy_finalize_before for that site.
  • Does Trellis run Laravel queues and the scheduler? No. Trellis has no hook for either. Add a systemd unit for the queue worker (for example Horizon) and a cron entry running schedule:run every minute through your own playbook.
  • Will a Laravel vhost affect my WordPress sites on the same Trellis server? It can. trellis provision renders Nginx for every site and reloads it once, so a bad Laravel vhost can break the others. Test on the dev VM and scope provisioning with tags.
  • Does Trellis build Laravel frontend assets on the server? Not by default for a Laravel site, and a server without Node cannot. Build on the machine running the deploy with delegate_to: localhost and upload the compiled public/build directory with synchronize.
  • Do I need to seed the database on the first Laravel deploy on Trellis? Usually yes, once. Migrations create the tables but not the starter data. Run php artisan db:seed --force over SSH after the first deploy, keep it out of the deploy hooks, and change the placeholder password on every seeded account straight away.
  • Is it better to use a separate server for Laravel? Often, yes. A separate VPS with Deployer, Forge or Ploi gives real isolation and removes the WordPress opt-outs. Running Laravel on Trellis makes sense for a single app when you already run the stack and want to avoid another server.

Done Managing Your Own Server?

We offer managed WordPress hosting built on Trellis — Nginx, PHP 8.3, Redis, automated deployments via Ansible, and Bedrock structure on Hetzner EU. No shared hosting, no page builders, no surprises.

  • Trellis + Bedrock on Hetzner EU (Frankfurt / Helsinki)
  • Nginx + FastCGI caching + Redis object cache
  • Automated deployments via Ansible, SSL via Let’s Encrypt
  • From €49/month — or €65/hour for one-off server work

Leave a Reply

Your email address will not be published.