DEV Community

Russell Jones
Russell Jones

Posted on • Originally published at jonesrussell.github.io on

Manage DigitalOcean Infrastructure With Ansible for Laravel and PHP Apps

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
  • .env files (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/
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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-server role 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 off directive breaks caddy 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-laravel while your config says my-app. Added a db_name field to decouple them.
  • .env files need mode 0640, not 0600. PHP-FPM runs as www-data, which needs group read access. Added www-data to the deployer group.

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
Enter fullscreen mode Exit fullscreen mode

Just the web server:

ansible-playbook playbooks/webserver.yml
Enter fullscreen mode Exit fullscreen mode

A single role:

ansible-playbook playbooks/webserver.yml --tags caddy
Enter fullscreen mode Exit fullscreen mode

Just the app configs:

ansible-playbook playbooks/webserver.yml --tags laravel-app
Enter fullscreen mode Exit fullscreen mode

The playbook is idempotent. Run it once or ten times, you get the same result.

Baamaapii

Top comments (0)