Skip to content

Trellis Explained: Zero-Downtime WordPress Deployments with Ansible

Emnes
Illustration of Trellis release directories with atomic symlink flip for zero downtime deploys

Deploying a WordPress site still means, for most teams, SFTP-ing files to the server and hoping nothing breaks during the upload. If the FTP transfer is interrupted, the site breaks. If a plugin update fails halfway, the site breaks. If you need to roll back, you’re restoring from a backup taken however many hours ago. This is how WordPress deploys worked in 2010, and for a distressing number of production sites, it’s still how they work in 2026.

Trellis — the Ansible-based provisioning and deployment tool from Roots.io — solves this with zero-downtime atomic deployments, the same deployment pattern used by every professional PHP application framework for the last decade. Every deploy creates a new timestamped release directory. The site only switches to the new release at the last moment, via a single atomic symlink flip. If anything goes wrong, rollback is one command and takes a second.

This post walks through exactly how Trellis deploys work — from the first trellis deploy command to the release symlink flip — and the Ansible internals that make the whole thing reliable. By the end, you’ll understand what happens during a deploy, how to customize it with hooks, how to roll back, and how this compares to other WordPress deployment strategies.

What “Zero-Downtime Deploy” Actually Means

A zero-downtime deploy has three guarantees:

  • No request ever sees a half-deployed site. Either the whole new version is live, or the old version is. Never both.
  • The transition happens in a single atomic operation. The deploy completes instantly from the web server’s perspective — no window of “which version responds to this request?”
  • Rollback is fast. If the new version is broken, reverting is a single operation that takes seconds, not minutes.

The standard way to achieve this is the symlink-flip pattern:

  • The web server’s document root is a symlink: /srv/www/example.com/current/srv/www/example.com/releases/20260418142032.
  • Each deploy creates a new release directory (with a timestamp as the name) containing the complete new version of the code.
  • After the new release is fully set up — code, dependencies, shared files linked in — the deploy flips the current symlink to point at the new directory.
  • Old releases stick around for rollback; Trellis keeps the last 5 by default.

This is the Capistrano pattern, used by Rails deploys for two decades. Trellis brings it to WordPress.

The Trellis Deploy Layout

On a Trellis-provisioned server, each WordPress site lives under /srv/www/<domain>/ with this structure:

/srv/www/example.com/
├── current -> releases/20260418142032 # symlink to current release
├── releases/
│ ├── 20260418142032/ # timestamped release dirs
│ ├── 20260417094501/
│ ├── 20260416201014/
│ ├── 20260415112308/
│ └── 20260414155142/ # oldest kept release
└── shared/
    ├── .env # production secrets
    └── uploads/ # media library

The shared/ directory contains anything that persists across deploys — the .env file with production credentials, and the uploads/ media library. Each release symlinks these in, so they don’t need to be copied on every deploy.

nginx’s document root points at /srv/www/example.com/current/web/ (Bedrock’s web root inside the current release). Because current is a symlink, nginx transparently serves whichever release it points at.

What Happens During trellis deploy

When you run trellis deploy production example.com, Ansible executes the deploy playbook, which does (in order):

1. Initial Checks

Ansible verifies the server is reachable, the environment variables are set, and the site is defined in wordpress_sites.yml.

2. Clone Code into New Release Directory

A new timestamped directory is created under releases/. Trellis clones your Git repository into it at the specified branch (usually main or the value of branch in wordpress_sites.yml).

3. Link Shared Files

Symlinks are created from the new release to shared files:

  • releases/NEW/.envshared/.env
  • releases/NEW/web/app/uploadsshared/uploads

Now the new release has production credentials and access to the shared media library, without any files being copied.

4. Run Build-Before Hooks

Trellis runs the deploy-hooks/sites/<domain>/build-before.yml playbook, if it exists. This is where site-specific prerequisites run — downloading assets from CI, setting up premium plugin auth, etc.

5. Composer Install

composer install --no-dev --optimize-autoloader runs inside the new release directory. This resolves all PHP dependencies (WordPress core, plugins, themes, packages) and writes them to the release’s vendor/ directory.

6. Run Build-After Hooks

deploy-hooks/sites/<domain>/build-after.yml runs. This is where post-Composer work happens — running the Sage theme’s pnpm run build to compile assets, rsyncing built files, clearing caches, etc.

7. Finalize Before Symlink Flip

The deploy runs any “finalize” steps — running WordPress database migrations with WP-CLI, warming caches, etc. At this point the new release is fully ready to serve traffic.

8. Atomic Symlink Flip

The current symlink is updated atomically to point at the new release. This is a single filesystem operation — on POSIX, rename() of a symlink is atomic, so there’s no window where nginx might see a half-updated symlink.

The moment the symlink flips, all new requests hit the new release. In-flight requests (ones nginx is still proxying to PHP-FPM) finish on whichever release they started on, which is fine — both releases are fully functional.

9. PHP-FPM Reload

Trellis gracefully reloads PHP-FPM so workers pick up the new code. Graceful reload waits for in-flight requests to finish before recycling each worker — no dropped connections.

10. Cleanup Old Releases

Releases older than the 5th most recent are deleted to save disk space. If you need more history, configure keep_releases in group_vars.

Customizing the Deploy with Hooks

Every Trellis deploy is fully hookable. Site-specific deploy customization lives in deploy-hooks/sites/<domain>/:

deploy-hooks/sites/example.com/
├── build-before.yml # before Composer install
├── build-after.yml # after Composer install, before symlink flip
├── finalize-after.yml # after symlink flip
└── update.yml # runs for every deploy, useful for shared steps

A typical build-after.yml for a Sage theme site:

- name: Install theme Node dependencies
  command: pnpm install --frozen-lockfile
  args:
    chdir: "{{ deploy_helper.new_release_path }}/web/app/themes/your-theme"

- name: Build theme assets
  command: pnpm run build
  args:
    chdir: "{{ deploy_helper.new_release_path }}/web/app/themes/your-theme"

Hooks are real Ansible — you have the full power of Ansible modules, conditionals, and loops available.

Rollback

If a deploy goes wrong, rollback is one command:

trellis rollback production example.com

This flips the current symlink back to the previous release. PHP-FPM reloads. The site is back to the previous version in under a second.

You can roll back multiple releases by running rollback multiple times, or by manually pointing current at a specific release directory. This is why Trellis keeps the last 5 releases — you have a multi-step rollback window.

Limits of rollback:

  • Database changes don’t roll back automatically. If the broken deploy ran a database migration, rolling back the code leaves the database in the migrated state. Use reversible migrations, or accept that rollback is only for code issues, not schema issues.
  • Uploaded files in the rollback window are preserved because they’re in shared/uploads/.
  • Cache may need to be cleared after rollback. Redis and page caches could hold keys from the newer version.

How Trellis Compares to Other WordPress Deploy Strategies

vs. SFTP/FTP Upload

SFTP is not atomic. Files arrive one at a time over seconds to minutes. During that window, the site serves a mix of old and new files. If the upload fails midway, the site is broken until you fix it manually. Trellis’s symlink flip eliminates all of this.

vs. Git Pull

Running git pull on the production server is slightly better than SFTP (atomic on the repo side), but doesn’t handle Composer install, asset builds, or cache warming. It also doesn’t give you a rollback window unless you manually tag releases. Trellis formalizes the workflow.

vs. Deployer / Envoyer

Deployer (PHP) and Envoyer (paid service) use the same symlink-flip pattern. They’re good alternatives if you don’t want Ansible. Trellis wins when you also want server provisioning, since it handles both in one tool.

vs. Managed WordPress Hosts

Kinsta, WP Engine, Pantheon, and similar handle deploys on your behalf with their own zero-downtime approaches. If you’re already on a managed host, you don’t need Trellis for deploys — you use the host’s deploy workflow. Trellis is for teams running their own VPS infrastructure.

CI/CD Integration

Trellis deploys are ideal for CI/CD. A standard GitHub Actions workflow:

  • On push to main, run the test suite.
  • If tests pass, trigger trellis deploy production example.com from the CI runner.
  • Ansible runs the deploy on the production server using SSH credentials stored as CI secrets.
  • Deploy completes in 1–3 minutes typically (longer for sites with large asset builds).

Trellis also supports deploying from your local machine, which is useful for emergency deploys when CI is down or for teams too small to set up CI.

Frequently Asked Questions

How long does a Trellis deploy take?

For a typical site: 1–3 minutes end-to-end. Composer install is usually the longest step (45 seconds to 2 minutes depending on plugin count). Asset builds add another minute or two for Sage themes. The symlink flip itself is milliseconds.

Does Trellis work without Bedrock?

Trellis is designed for Bedrock. It technically works for any Composer-managed WordPress project with a compatible directory layout, but you’ll fight the defaults at every step. Use Bedrock if you’re using Trellis.

What happens if a deploy fails midway?

The failed release directory stays on disk but the current symlink is never updated. Traffic continues hitting the previous release. You investigate the failure, fix it, and re-run the deploy. Nothing to roll back.

How do database migrations fit in?

Run them in build-after.yml (before the symlink flip) via WP-CLI. Ideally, make them backward-compatible so the old release still works if rollback is needed. For breaking schema changes, accept that rollback may require manual intervention.

Can I customize how many releases are kept?

Yes. Set keep_releases in your group_vars. Default is 5. More releases = more disk space, more rollback window.

Does Trellis support multiple sites on one server?

Yes. Each site gets its own /srv/www/<domain>/ directory with independent releases. You can have dozens of sites on one Trellis-provisioned server.

Deploys Should Be Boring

A good deploy is one you don’t think about. You push code, CI runs tests, CI triggers trellis deploy, the site updates, you move on. No SFTP, no “hope nothing breaks,” no midnight emergency uploads. Just the same reliable zero-downtime flow every time.

At Emnes, we run Trellis across 18 production sites with the same deploy workflow on every one. The discipline of atomic releases plus rollback-in-one-command has saved us from multiple incidents that would have been hours of downtime with FTP. If you’re still deploying WordPress with FTP or ad-hoc scripts, the switch to Trellis pays for itself on the first prevented outage.

Related reading: the Roots.io stack explained, upcoming posts on migrating Trellis from Vagrant to Lima and provisioning a production-ready WordPress server in 30 minutes, and the Bedrock complete guide.