Ahnii!
This post walks through how to build an Ansible repo to manage a production DigitalOcean setup: two Ubuntu droplets, six Laravel apps, a couple of PHP framework sites, and a Go microservices platform. The goal is to codify everything that was previously managed via manual SSH, without replacing the Deployer workflow that already handles app releases.
The Problem
The setup worked. But it was held together by tribal knowledge. Adding a new site meant SSHing in, creating directories, writing a Caddyfile, setting up systemd services, creating a database, and hoping you remembered every step. Server config drifted over time. Nothing was reproducible.
I needed a single source of truth for what the server should look like.
What Ansible Manages (and What It Doesn't)
This is the key design decision. Deployer already handles release deploys: building assets, uploading artifacts, symlinking releases, restarting services. It does that job well. Ansible handles everything else.
Ansible owns:
- DigitalOcean droplets, DNS records, and firewall rules
- Server packages (Caddy, PHP-FPM, MariaDB, Node.js, Docker)
- SSH hardening, UFW, fail2ban, swap
- Per-app directories, Caddyfiles, and log rotation
- Database creation and user provisioning
-
.envfiles (from Vault-encrypted secrets)
Deployer owns:
- Release artifact upload and symlink switching
- Systemd user services (Horizon, SSR, scheduler, subscribers)
- Cache clearing and migration running
- Rollback
No overlap. Ansible sets up the environment. Deployer deploys into it.
Repo Structure
infra-ansible/
ansible.cfg
requirements.yml
inventory/
hosts.yml
group_vars/
all/
main.yml
vault.yml # DO API token (encrypted)
digitalocean.yml # droplets, DNS, firewalls
webservers/
main.yml # php_version, extensions
vault.yml # MariaDB root password
host_vars/
web-prod/
main.yml # app definitions
vault.yml # per-app secrets
proxy-01/
main.yml
playbooks/
site.yml # full convergence
webserver.yml
proxy.yml
provision-droplet.yml
destroy-droplet.yml
roles/
common/
caddy/
php/
mariadb/
node/
laravel-app/
php-framework-app/
north-cloud/
crawl-proxy/
digitalocean/
The inventory has two hosts. web-prod runs everything (Laravel apps, PHP framework sites, Go microservices). proxy-01 is a crawl proxy for the content pipeline's URL frontier.
Apps Are Data
Adding a new Laravel app doesn't require a new role or playbook. You add an entry to host_vars/web-prod/main.yml:
laravel_apps:
- name: my-laravel-app
domain: example.com
repo: yourorg/my-laravel-app
db: mariadb
db_name: myapp
app_key: "{{ vault_myapp_app_key }}"
db_password: "{{ vault_myapp_db_password }}"
- name: another-app
domain: another.example.com
repo: yourorg/another-app
db: mariadb
app_key: "{{ vault_another_app_key }}"
db_password: "{{ vault_another_db_password }}"
The laravel-app role loops over this list. For each app it creates the directory structure, deploys a Caddyfile from a template, pre-creates log files with correct ownership, and optionally deploys the .env.
# roles/laravel-app/tasks/main.yml
- name: Configure Laravel apps
ansible.builtin.include_tasks: app.yml
loop: "{{ laravel_apps }}"
loop_control:
loop_var: app
label: "{{ app.name }}"
One role, many apps.
Caddy Configuration With Glob Imports
The old /etc/caddy/Caddyfile had a dozen explicit import lines, one per site. Every new site meant SSHing in and appending a line. Now Ansible deploys a two-line Caddyfile:
import /home/deployer/*/Caddyfile
import /opt/*/Caddyfile
Each app gets its own Caddyfile in its deploy directory, templated by Ansible:
{{ app.domain }} {
tls {
issuer acme {
}
}
root * /home/{{ deploy_user }}/{{ app.name }}/current/public
encode gzip zstd
@static {
path /css/* /js/* /img/* /build/* *.ico
}
handle @static {
header Cache-Control "public, max-age=31536000, immutable"
file_server
}
php_fastcgi * unix//run/php/php{{ php_version }}-fpm.sock {
resolve_root_symlink
}
log {
output file /home/{{ deploy_user }}/{{ app.name }}/log/access.log {
mode 0644
}
}
}
New apps are picked up automatically by the glob. The Caddy handler always validates before reloading, so a bad config never takes down other sites.
DigitalOcean as Code
Droplets, DNS records, and firewalls are declared in group_vars/all/digitalocean.yml and managed through the community.digitalocean collection:
do_droplets:
- name: web-prod
region: tor1
size: s-2vcpu-4gb
image: ubuntu-24-04-x64
tags: [prod]
do_domains:
- domain: example.com
records:
- { type: A, name: "@", value: "203.0.113.10" }
- { type: A, name: www, value: "203.0.113.10" }
do_firewalls:
- name: web-traffic
inbound_rules:
- { protocol: tcp, ports: "443", sources: { addresses: ["0.0.0.0/0"] } }
- { protocol: tcp, ports: "80", sources: { addresses: ["0.0.0.0/0"] } }
- { protocol: tcp, ports: "22", sources: { addresses: ["0.0.0.0/0"] } }
tags: [prod, proxy]
Provisioning a new droplet is one command: ansible-playbook playbooks/provision-droplet.yml.
Secrets With Ansible Vault
Server secrets live in encrypted vault files committed to the repo. The vault password file lives at ~/.ansible-vault-password and is gitignored.
Vault variables use a vault_ prefix. Clear-text vars reference them:
# vault.yml (encrypted)
vault_myapp_app_key: "base64:abc123..."
vault_myapp_db_password: "s3cret-passw0rd"
# main.yml (clear)
laravel_apps:
- name: my-laravel-app
app_key: "{{ vault_myapp_app_key }}"
db_password: "{{ vault_myapp_db_password }}"
SSH deploy keys and GitHub Actions secrets stay where they are. No duplication.
Lessons From the First Real Run
Running this against a live production server surfaced several things that a dry-run couldn't catch:
-
Redis runs in Docker, not as a system package. The
redis-serverrole tried to bind to a port Docker already owned. Removed the role entirely. - Ondrej PHP PPA and Docker repo were already installed with different GPG key paths. Adding them again caused apt conflicts. Fixed with existence checks.
- MariaDB uses unix socket auth on Ubuntu. Setting a root password broke subsequent tasks. Removed the password task entirely.
-
Caddy's
admin offdirective breakscaddy reload. The reload command uses the admin API on localhost:2019. Removed it. -
App directory names don't always match app names. The deploy directory might be
my-app-laravelwhile your config saysmy-app. Added adb_namefield to decouple them. -
.envfiles need mode 0640, not 0600. PHP-FPM runs aswww-data, which needs group read access. Addedwww-datato thedeployergroup.
Each of these would have been a "why is the site down?" mystery without the Ansible run surfacing it explicitly.
Running It
Full convergence (everything from DO infra to app config):
ansible-playbook playbooks/site.yml
Just the web server:
ansible-playbook playbooks/webserver.yml
A single role:
ansible-playbook playbooks/webserver.yml --tags caddy
Just the app configs:
ansible-playbook playbooks/webserver.yml --tags laravel-app
The playbook is idempotent. Run it once or ten times, you get the same result.
Baamaapii
Top comments (0)