Deploying Laravel with Ansible — From "SSH and Pray" to One Command
"SSH and Pray"
You know the ritual. A change is ready, so you ssh into the server, cd into the project, and start typing from memory:
git pull origin main
composer install --no-dev
php artisan migrate --force
php artisan config:cache
php artisan route:cache
sudo systemctl reload php8.3-fpm
If you're lucky, that's the whole dance. If you're not, you forgot the --no-dev and dragged your dev dependencies onto production, or you skipped config:cache so the old config is still baked in, or you ran the migration against the wrong database. (I've written about the day I nearly ran a destructive command against the production database — that near-miss lives in the same family of problems as manual deploys.)
The core issue isn't any single command. It's that the whole process lives in your head and your fingers. It's not repeatable, not reviewable, and not safe. Every deploy is a fresh chance to fat-finger something at 11pm. And the moment a second person — or a second server — enters the picture, "the way we deploy" becomes folklore.
This is exactly the problem Ansible solves. By the end of this post you'll have replaced that entire ritual with:
ansible-playbook -i inventory/production.ini deploy.yml
One command. Same result every time. Zero downtime. And if something's wrong, you find out before it touches the server.
Let's build it properly — for a real Laravel + PostgreSQL app on an Ubuntu VPS behind Nginx and PHP-FPM.
What Ansible Actually Is (in 90 Seconds)
Ansible is a tool for describing the state you want a server to be in, and then making it so. Three things make it click:
- It's agentless. There's nothing to install on the server. Ansible connects over plain SSH and runs commands. Your laptop (or a CI runner) is the "control node"; the server is a "managed node." If you can SSH in, you can Ansible it.
- You describe state, not steps. Instead of "run
apt install nginx," you say "nginx should be installed." Ansible checks first and only acts if reality doesn't match. That property is called idempotency, and it's the whole game — you can run the same playbook ten times and the ninth and tenth runs change nothing. - It's just YAML. A playbook is a readable list of tasks. No DSL to learn, no daemon to babysit. It diffs cleanly in a pull request.
A Bash deploy script does things. An Ansible playbook guarantees things. That difference — "make it so" versus "run these commands and hope" — is why a playbook is safe to re-run and a script usually isn't.
That's enough theory. Let's look at what we're building.
What We're Building
The target architecture is deliberately ordinary, because most real Laravel apps look like this:
- One Ubuntu 24.04 VPS (the pattern scales to many — more on that later).
- Nginx serving the app, proxying PHP to PHP-FPM 8.3.
- PostgreSQL as the database (the client on the app server; the DB can be local or managed).
- Code deployed from Git, dependencies via Composer.
And we'll split the automation into two playbooks, because they answer two different questions:
| Playbook | Runs | Answers |
|---|---|---|
provision.yml | Once (and whenever server config changes) | "Is this box set up to run the app?" |
deploy.yml | Every release | "Is the latest code live, safely?" |
The deploy playbook uses a release-based, atomic-symlink layout (the same idea Capistrano and Laravel Envoyer use). Each deploy builds a fresh, timestamped release directory and then flips a single current symlink to point at it. Because a symlink swap is atomic, there's no moment where the app is half-updated — the switch is instant, and rolling back is just pointing the symlink at the previous release.
Here's the directory structure we're aiming for on the server:
/var/www/myapp/
├── releases/
│ ├── 20260715T120001/ # a past release
│ └── 20260715T123045/ # the release we just built
├── shared/
│ ├── .env # persists across releases
│ └── storage/ # user uploads, logs, cache
└── current -> releases/20260715T123045 # the live symlink
The shared/ directory holds everything that must survive a deploy — the .env and Laravel's storage/ — and each release symlinks to it. Nginx's document root points at current/public, so flipping current flips the whole site.
Installing Ansible and First Contact
Ansible runs on your machine, not the server. On macOS or Linux:
python3 -m pip install --user ansible
# or: brew install ansible / pipx install ansible
ansible --version
On Windows, there's a catch: Ansible has no native Windows control node — the machine you run ansible-playbook from must be Linux or macOS. (Windows can be a target Ansible deploys to over WinRM, but that's a different job.) The clean, officially-recommended fix is WSL2 — the Windows Subsystem for Linux — which gives you a real Ubuntu inside Windows:
# In an elevated PowerShell — installs WSL2 and Ubuntu in one shot
wsl --install -d Ubuntu
Reboot if it asks, then launch Ubuntu from the Start menu (or grab it from the Microsoft Store if you want to pick a specific release), set your Linux username and password, and run the exact same pip/pipx install inside that Ubuntu shell. From there Ansible behaves identically to Linux — your Windows drives are mounted under /mnt/c/, and your SSH keys live in the WSL home at ~/.ssh/. Do all your Ansible work inside WSL, not in PowerShell.
Now tell Ansible about your servers. This is the inventory — a plain file listing hosts and how to reach them:
# inventory/production.ini
[web]
app1.example.com
[web:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3
The [web] group is a label we'll target in playbooks. The [web:vars] block says "connect as the deploy user." Ansible authenticates with your SSH key, so make sure you can already ssh deploy@app1.example.com without a password prompt.
Drop a small ansible.cfg next to it so you don't have to repeat flags:
# ansible.cfg
[defaults]
inventory = inventory/production.ini
host_key_checking = False
retry_files_enabled = False
host_key_checking = Falseis convenient for a tutorial and terrible as a habit — it disables the protection that warns you when a host's SSH fingerprint changes (a classic man-in-the-middle signal). On real infrastructure, leave it on and manageknown_hostsproperly.
Verify the connection with an ad-hoc command — no playbook required:
ansible web -m ping
app1.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
pong means Ansible reached the box, ran a Python module, and got a clean result. You're connected. (ping here isn't ICMP — it's an Ansible module that proves the whole SSH-and-Python round trip works.)
Project Layout
Before writing tasks, give the project a home. A flat pile of playbooks becomes unmaintainable fast, so lean on Ansible's conventions from day one:
myapp-deploy/
├── ansible.cfg
├── inventory/
│ └── production.ini
├── group_vars/
│ └── web.yml # variables for every host in [web]
├── templates/
│ └── nginx-site.conf.j2
├── provision.yml
└── deploy.yml
group_vars/web.yml is where the knobs live — everything specific to your app in one place, so the playbooks stay generic:
# group_vars/web.yml
app_name: myapp
app_repo: "git@github.com:you/myapp.git"
app_branch: main
deploy_user: deploy
deploy_path: /var/www/myapp
php_version: "8.3"
domain: myapp.example.com
keep_releases: 5
Now the interesting part.
Provisioning the Server (Once)
provision.yml gets a bare Ubuntu box ready to run Laravel: system packages, PHP and its extensions, Composer, Nginx, and the directory skeleton. It's one play with a list of tasks, each declaring the state it wants.
# provision.yml
---
- name: Provision the Laravel server
hosts: web
become: true # run as root (sudo) for system-level changes
vars:
php_packages:
- "php{{ php_version }}-fpm"
- "php{{ php_version }}-cli"
- "php{{ php_version }}-pgsql"
- "php{{ php_version }}-mbstring"
- "php{{ php_version }}-xml"
- "php{{ php_version }}-curl"
- "php{{ php_version }}-zip"
- "php{{ php_version }}-bcmath"
- "php{{ php_version }}-gd"
tasks:
- name: Add the ondrej/php PPA (modern PHP builds)
ansible.builtin.apt_repository:
repo: ppa:ondrej/php
update_cache: true
- name: Install system packages
ansible.builtin.apt:
name:
- nginx
- git
- unzip
- acl
- postgresql-client
state: present
update_cache: true
cache_valid_time: 3600
- name: Install PHP and extensions
ansible.builtin.apt:
name: "{{ php_packages }}"
state: present
- name: Download the Composer installer
ansible.builtin.get_url:
url: https://getcomposer.org/installer
dest: /tmp/composer-setup.php
mode: "0644"
- name: Install Composer globally
ansible.builtin.command:
cmd: php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
creates: /usr/local/bin/composer # <-- makes this task idempotent
- name: Create the deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
shell: /bin/bash
groups: www-data
append: true
- name: Create the release directory skeleton
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ deploy_user }}"
group: www-data
mode: "0775"
loop:
- "{{ deploy_path }}"
- "{{ deploy_path }}/releases"
- "{{ deploy_path }}/shared"
- "{{ deploy_path }}/shared/storage"
- name: Deploy the Nginx site config
ansible.builtin.template:
src: templates/nginx-site.conf.j2
dest: "/etc/nginx/sites-available/{{ app_name }}"
mode: "0644"
notify: Reload nginx
- name: Enable the site
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ app_name }}"
dest: "/etc/nginx/sites-enabled/{{ app_name }}"
state: link
notify: Reload nginx
- name: Remove the default Nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
A few things worth calling out, because they're the difference between a playbook that works and one that's correct:
creates:on the Composer task.commandandshellare the two modules Ansible can't reason about — it has no idea whether running an arbitrary command changed anything, so it reportschangedevery single time. Addingcreates: /usr/local/bin/composertells it "skip this if that file already exists," which restores idempotency. Without it, every provision run re-downloads and re-installs Composer for no reason.aclin the package list. This one saves you an afternoon. Laravel needsstorage/andbootstrap/cache/writable by the PHP-FPM process (which runs aswww-data), while your deploy user owns the files. Theaclpackage lets Ansible set fine-grained permissions so both can coexist — without it, you get the eternalThe stream or file "storage/logs/laravel.log" could not be opened500 error.- Handlers. Notice the three Nginx tasks say
notify: Reload nginxinstead of reloading inline. A handler runs once, at the end of the play, and only if something notified it. Change the config and the symlink and remove the default site, and Nginx still reloads exactly once. If nothing changed, it doesn't reload at all. That's idempotency doing your thinking for you.
The Nginx template (templates/nginx-site.conf.j2) is a normal config file with a couple of {{ variables }} filled in at deploy time:
server {
listen 80;
server_name {{ domain }};
root {{ deploy_path }}/current/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# $realpath_root (not $document_root) resolves the symlink to the real
# release path — critical so OPcache keys on the actual file, not the
# stale symlink target, after a release swap.
fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Run it once:
ansible-playbook provision.yml
The box is now ready. You'll only re-run this when the server's configuration changes — a new PHP extension, a tweaked Nginx block. Day-to-day, you live in the deploy playbook.
The Deploy Playbook
This is the heart of the whole thing. Every task here runs as the unprivileged deploy_user, and the shape follows the release pattern: build a new release, wire it up, then flip the symlink.
# deploy.yml
---
- name: Deploy the Laravel app
hosts: web
become: true
become_user: "{{ deploy_user }}"
vars:
release_ts: "{{ ansible_date_time.iso8601_basic_short }}"
release_path: "{{ deploy_path }}/releases/{{ release_ts }}"
tasks:
- name: Check out the code into a fresh release directory
ansible.builtin.git:
repo: "{{ app_repo }}"
dest: "{{ release_path }}"
version: "{{ app_branch }}"
depth: 1
accept_hostkey: true
- name: Link the shared .env into the release
ansible.builtin.file:
src: "{{ deploy_path }}/shared/.env"
dest: "{{ release_path }}/.env"
state: link
- name: Link the shared storage directory into the release
ansible.builtin.file:
src: "{{ deploy_path }}/shared/storage"
dest: "{{ release_path }}/storage"
state: link
force: true # the repo ships an empty storage/; replace it
- name: Install Composer dependencies (production only)
ansible.builtin.command:
cmd: composer install --no-dev --optimize-autoloader --no-interaction
chdir: "{{ release_path }}"
- name: Run database migrations
ansible.builtin.command:
cmd: php artisan migrate --force
chdir: "{{ release_path }}"
- name: Cache config, routes, and views
ansible.builtin.command:
cmd: "php artisan {{ item }}"
chdir: "{{ release_path }}"
loop:
- config:cache
- route:cache
- view:cache
- name: Activate the release (atomic symlink swap)
ansible.builtin.file:
src: "{{ release_path }}"
dest: "{{ deploy_path }}/current"
state: link
force: true
- name: Reload PHP-FPM to clear OPcache
ansible.builtin.systemd:
name: "php{{ php_version }}-fpm"
state: reloaded
become_user: root # this one step needs root
- name: Find all releases
ansible.builtin.find:
paths: "{{ deploy_path }}/releases"
file_type: directory
register: releases
- name: Prune old releases, keeping the newest {{ keep_releases }}
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ (releases.files | sort(attribute='mtime', reverse=true))[keep_releases:] }}"
Walk through what a run does:
- Clone into a new timestamped directory.
ansible_date_time.iso8601_basic_shortgives something like20260715T123045, so every deploy is isolated.depth: 1grabs only the latest commit — faster clones, less disk. - Symlink the shared bits in. The
.envandstorage/point back atshared/, so config and uploads persist across releases. The repo's own emptystorage/gets replaced by the symlink (force: true). - Build the release — Composer with
--no-dev(no dev tooling on prod) and--optimize-autoloader, then migrations, then Laravel's caches. All of this happens in the release directory while the old release is still live and serving traffic. - Flip
current. This is the deploy. One atomic symlink swap and the new code is live. Nothing before this line was visible to users; everything after it is. - Reload PHP-FPM. OPcache holds compiled PHP in memory keyed by file path. A graceful
reloadclears it so the new code is actually executed and not served from the previous release's cache. (This is why the Nginx$realpath_rootdetail earlier matters.) - Prune. Keep the last five releases for instant rollback; delete the rest.
That rollback, by the way, is almost free. Because the old releases are still on disk, reverting is just re-pointing current at the previous one and reloading PHP-FPM — a five-line emergency playbook you'll be grateful for one day.
Secrets, Briefly
The .env sitting in shared/ holds your database password, app key, and API tokens. That file should never live in your Git repo or in a plaintext Ansible variable. The episode that kicked off this post was all about Ansible Vault, which is the right tool here: it encrypts sensitive values at rest so you can commit them safely and decrypt them only at deploy time.
The workflow is small:
ansible-vault create group_vars/web/vault.yml # create, opens $EDITOR
ansible-vault edit group_vars/web/vault.yml # change it later
ansible-playbook deploy.yml --ask-vault-pass # decrypt at run time
You'd then template the .env onto the server from those vaulted variables. That's enough to keep secrets out of version control; I'll leave the deeper Vault patterns (per-environment vault IDs, CI integration) for another day — the Vault guide covers them well.
Zero-Downtime for Real
On a single box, the atomic symlink swap already gives you zero-downtime deploys: there's no window where the app is half-updated. But the day you outgrow one server and put two or three app nodes behind a load balancer, "deploy all of them at once" becomes "take the whole site down at once if a release is bad."
The fix is three ideas working together, and Ansible has first-class support for all of them:
- name: Deploy the Laravel app (rolling)
hosts: web
become: true
serial: 1 # one server at a time
max_fail_percentage: 0 # stop the instant any host fails
tasks:
# ... all the deploy steps from above ...
- name: Wait until the new release answers healthy
ansible.builtin.uri:
url: "http://localhost/up" # Laravel's built-in health endpoint
status_code: 200
register: health
retries: 5
delay: 3
until: health.status == 200
become_user: root
serial: 1processes hosts one at a time instead of all at once, so the rest of the fleet keeps serving while one node updates. You can also use batches (serial: 2) or percentages (serial: "25%"), or a progressive ramp (serial: [1, 2, "50%"]) that tests the waters with one box before committing.max_fail_percentage: 0halts the entire rollout the moment a single host fails, so a broken release stops after one server instead of taking down all of them.- A health check (Laravel ships a
/upendpoint) verifies the node is actually serving before Ansible moves to the next one. In a real load-balanced setup you'd alsodelegate_tothe load balancer to pull each node out of the pool before updating and add it back after the health check passes.
The official rolling-upgrade guide goes deep on the load-balancer choreography if you need it. For most single-VPS Laravel apps, the atomic symlink swap is all the zero-downtime you'll ever need.
The Gotchas Nobody Warns You About
The happy path above is the easy 80%. Here's the 20% that costs people real time — the things the tutorials skip.
Idempotency isn't free — you design for it
This is the single most important idea, and it's easy to get wrong. Ansible modules like apt, file, and git are idempotent because they check state first. But command and shell are not — Ansible can't understand an arbitrary command, so it runs it and reports changed every time.
| Module | Idempotent? | How to make it behave |
|---|---|---|
apt, file, template, git, user | Yes, natively | Nothing to do |
command, shell | No | Add creates:, removes:, or changed_when: |
The one reliable test: run your playbook twice and read the second run. A clean second pass reports changed=0. Any non-zero count means a task isn't converging — usually a naked shell/command that should have a creates: or a changed_when::
- name: Seed the app only once
ansible.builtin.command: php artisan db:seed --force
args:
creates: "{{ release_path }}/storage/.seeded"
(The deploy playbook's composer install and artisan tasks report changed every run, and that's fine — each deploy builds a brand-new release directory, so there's genuinely new work each time. Idempotency matters most in provision.yml, which you re-run against the same state.)
--check and --diff are your seatbelt
Before any deploy you're nervous about, dry-run it:
ansible-playbook deploy.yml --check --diff
--check predicts every change without touching the server; --diff shows you the before/after of every file it would write. This is the guardrail that manual SSH deploys never had — you get to see what's about to happen before it does. (It's not perfect: tasks that depend on a previous task's real output can mispredict in check mode, and some modules don't support it. But it catches the obvious disasters.)
Handlers don't fire if the play fails first
Handlers run at the end of the play. If a task fails before that, notified handlers never run — so a config change might be written to disk but the service never reloaded. When ordering genuinely matters (say, migrate before flipping the symlink), force handlers to run at a specific point with meta: flush_handlers, or just keep the critical sequencing as ordinary ordered tasks like we did above.
migrate --force is a loaded gun
--force exists to skip Laravel's "are you sure, this is production?" prompt — which is exactly the prompt that would save you from a destructive migration. Ansible runs non-interactively, so you need --force, but that means a bad migration ships with zero friction. Back up the database immediately before migrating, and treat destructive migrations (dropping columns, tables) with the same caution I learned the hard way with production data. A pg_dump task right before the migrate step is cheap insurance.
Permissions and become will bite you
Two processes touch your files: the deploy user (who owns the code) and www-data (who runs PHP-FPM and needs to write storage/ and bootstrap/cache/). Get this wrong and you get either permission-denied 500s or a security smell. This is what the acl package and the shared storage/ symlink are for. And remember that become/become_user is per-task-overridable: most of the deploy runs as deploy, but the PHP-FPM reload needs become_user: root.
OPcache serves stale code
If you deploy and the old behavior stubbornly persists, it's almost always OPcache holding the previous release's compiled files in memory. The reload of PHP-FPM in the deploy playbook clears it. Skip that step and your shiny new release is a symlink pointing at code nobody's actually running.
The Payoff
Here's what we replaced. The old ritual:
ssh deploy@app1.example.com
cd /var/www/myapp
git pull origin main
composer install --no-dev
php artisan migrate --force
php artisan config:cache
php artisan route:cache
sudo systemctl reload php8.3-fpm
# ...did I forget anything? did it work? is the site up?
The new one:
ansible-playbook -i inventory/production.ini deploy.yml
Same steps, but now they're written down, reviewable in a pull request, idempotent, dry-runnable, atomic, and identical whether it's you deploying at noon or a teammate deploying at midnight. A bad release rolls back in seconds by re-pointing a symlink. And the knowledge that used to live in your fingers now lives in a file your whole team can read.
Where to go from here, when you're ready:
- Trigger it from CI. Wire
ansible-playbookinto a GitHub Actions job so a merge tomaindeploys itself. - Split into roles. As the playbooks grow, break
provision.ymlinto reusable roles (php,nginx,postgres) — each self-contained and testable. - Test the playbooks themselves with Molecule, which spins up a throwaway container, runs your role, and asserts it converged — including that critical run-it-twice idempotency check.
But you don't need any of that to get the win. The win is the day deploying stops being a held-breath ritual and becomes one boring, repeatable command. That's the whole point of automation: make the scary thing routine, and the routine thing safe.