
August 16, 2026
10 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Manually configuring production servers is a liability that compounds with every new client or project. When you automate server setup with Ansible playbooks, you replace fragile SSH sessions and tribal knowledge with version-controlled, idempotent infrastructure code. For full-stack developers managing multiple Laravel applications or legal-tech portals in Nepal, this shift eliminates configuration drift and ensures that your staging environment actually matches production.
How do you structure Ansible playbooks to automate server setup reliably?
The most common mistake I see when developers first attempt to automate server setup with Ansible playbooks is putting everything into a single monolithic YAML file. This works for one server but becomes unmaintainable the moment you need to provision a second environment or share configuration between projects. In my experience maintaining sister sites like notarykathmandu.com and translationnepal.com on shared EC2 infrastructure, a role-based directory structure is non-negotiable for long-term sanity.
Your site.yml acts as the orchestrator, importing roles in dependency order. Each role encapsulates a single concern: the common role handles UFW firewall rules, fail2ban, and unattended upgrades; the php-fpm role manages PHP 8.4 installation and pool configuration; the nginx role deploys virtual host templates. This separation means you can reuse the same php-fpm role across a Laravel development project and a WooCommerce store without duplicating logic.
Variables belong in group_vars/ organized by inventory group, never hardcoded in tasks. Sensitive values like database passwords or API keys must be encrypted with ansible-vault. On real client projects, I maintain separate vault files for staging and production, decrypted only during CI/CD pipeline execution. This prevents secrets from leaking into Git history while keeping playbooks portable.
What does an idempotent Ansible playbook look like for Ubuntu 24.04?
Idempotency is the core principle that makes Ansible safe to run repeatedly. A properly written playbook produces the same result whether executed once or a hundred times. When you automate server setup with Ansible playbooks for Ubuntu 24.04 LTS, every task must check current state before making changes. Here is a practical base playbook I use as a starting point for new full-stack web development engagements:
---
- name: Base server hardening for Ubuntu 24.04
hosts: webservers
become: true
vars:
ssh_port: 2222
allowed_ssh_users: ['deploy', 'kokil']
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
- name: Install essential security packages
ansible.builtin.apt:
name:
- fail2ban
- ufw
- unattended-upgrades
- curl
- git
state: present
- name: Configure UFW default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
- name: Allow SSH on custom port
community.general.ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
- name: Enable UFW
community.general.ufw:
state: enabled
- name: Create deploy user with sudo access
ansible.builtin.user:
name: deploy
shell: /bin/bash
groups: sudo
append: yes
ssh_authorized_keys:
- "{{ lookup('file', 'files/deploy_ed25519.pub') }}"
state: present
- name: Disable root SSH login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
notify: Restart SSHD
handlers:
- name: Restart SSHD
ansible.builtin.service:
name: ssh
state: restarted Notice the explicit state: present declarations and the use of modules over raw shell commands. The lineinfile module checks if the line already exists before modifying it, preventing duplicate entries on subsequent runs. Handlers execute only when notified by a changed task, so SSHD restarts happen only when configuration actually changes. This pattern prevents unnecessary service interruptions during routine maintenance windows.
- Always use fully qualified collection names (e.g.,
ansible.builtin.apt) to avoid ambiguity and future deprecation warnings. - Prefer declarative modules over
shellorcommand; reserve raw commands for operations with no native module support. - Test idempotency by running the playbook twice consecutively; the second run should report zero changes.
- Use
--check --diffflags during development to preview changes without applying them.
How do you manage PHP 8.4 and Nginx configurations with Ansible roles?
For Laravel 12 and modern Symfony 7 applications, PHP 8.4 is the current stable target on Ubuntu 24.04. Managing PHP-FPM pools and Nginx virtual hosts through Ansible roles ensures consistency across development, staging, and production. The key is templating: configuration files should be Jinja2 templates that adapt to host-specific variables rather than static copies.
A typical PHP-FPM pool template (templates/www.conf.j2) uses variables for memory limits, max children, and socket paths:
[{{ app_name }}]
user = www-data
group = www-data
listen = /run/php/php{{ php_version }}-fpm-{{ app_name }}.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = {{ php_fpm_max_children | default(20) }}
pm.start_servers = {{ php_fpm_start_servers | default(5) }}
pm.min_spare_servers = {{ php_fpm_min_spare | default(3) }}
pm.max_spare_servers = {{ php_fpm_max_spare | default(10) }}
php_admin_value[memory_limit] = {{ php_memory_limit | default('256M') }}
php_admin_value[upload_max_filesize] = {{ php_upload_max | default('64M') }}
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.validate_timestamps] = {{ app_env == 'production' | ternary('0', '1') }} This template adapts automatically based on environment. In production, OPcache timestamp validation is disabled for performance; in staging, it remains enabled for easier debugging. The Nginx vhost template similarly references {{ nginx_server_name }} and {{ nginx_root }}, allowing the same role to deploy both a legal-tech portal and an eCommerce site simply by changing variables in group_vars/production.yml.
When integrating with deployment tools like Deployer 7, ensure your Ansible-managed PHP-FPM socket path matches what Deployer expects. On several projects I've maintained, mismatched socket paths between Ansible provisioning and Deployer releases caused intermittent 502 errors after deployment. Define the socket path as a shared variable accessible to both systems to prevent this class of bugs.
How does Ansible compare to manual setup and other IaC tools for PHP developers?
Understanding where Ansible fits in the infrastructure landscape helps you decide when to adopt it versus alternatives. For solo developers and small agencies building cost-sensitive web projects in Nepal, the comparison often comes down to learning curve versus operational overhead.
| Criteria | Manual SSH Setup | Ansible | Terraform + Cloud Init | Docker / Containers |
|---|---|---|---|---|
| Initial Learning Time | None (but error-prone) | 2–4 days for basics | 1–3 weeks | 1–2 weeks |
| Agent Required | N/A | No (SSH only) | No (API-based) | Container runtime |
| Idempotency | Manual discipline | Built-in by design | Built-in (declarative) | Image immutability |
| Best For | Single throwaway server | Multi-server config mgmt | Cloud resource provisioning | Microservices / local dev |
| Secret Management | Ad-hoc / risky | Ansible Vault (integrated) | External (Vault, SSM) | Env vars / secrets mgr |
| Nepal VPS Compatibility | Universal | Universal (SSH access) | Requires cloud API | Requires container support |
| Typical Cost Impact | High (time + errors) | Low (Rs 0 tooling cost) | Medium (cloud spend) | Medium (orchestration) |
For traditional VPS hosting commonly used in Nepal (where many clients prefer local providers or affordable DigitalOcean/Hetzner instances over complex AWS architectures), Ansible offers the best balance. It requires no agent installation, works over standard SSH, and integrates naturally with the GitLab CI + Deployer 7 workflow I use for client projects. Terraform excels at provisioning cloud resources but adds complexity for simple server configuration. Docker introduces runtime overhead and debugging friction that may not justify itself for monolithic Laravel applications serving moderate traffic.
What are common pitfalls when automating server setup with Ansible playbooks?
After years of using Ansible for production infrastructure, certain failure patterns recur. Recognizing these early saves hours of debugging and prevents fragile automation that breaks under pressure.
- Ignoring idempotency testing: Always run playbooks with
--check --diffbefore applying to production. A task that reports "changed" on every run indicates broken idempotency that will eventually corrupt state or cause unnecessary service restarts. - Hardcoding environment-specific values: Domain names, IP addresses, and credentials inside role templates create technical debt. Use
group_varsand inventory-specific variable files exclusively. When I onboard a new legal-tech client, I copy their variable file and adjust only the values that differ. - Neglecting handler ordering: Handlers execute in definition order, not notification order. If restarting Nginx depends on PHP-FPM being available, define the PHP-FPM handler first and ensure task ordering guarantees the dependency.
- Skipping vault encryption for secrets: Storing database passwords or API keys in plaintext YAML files is a security vulnerability. Use
ansible-vault encrypt_stringfor individual secrets or encrypt entire variable files. Integrate vault password files securely in CI/CD pipelines, never commit them. - Overusing shell/command modules: Raw shell commands bypass Ansible's state tracking and break idempotency unless wrapped with
createsorremovesparameters. Prefer native modules; reserve shell for genuinely unsupported operations. - Forgetting Python interpreter configuration: Ubuntu 24.04 ships Python 3.12 by default. If your control node or managed hosts have non-standard Python paths, set
ansible_python_interpreterexplicitly in inventory to avoid module failures.
Another practical issue specific to Nepal infrastructure: network reliability during playbook execution. When provisioning servers hosted locally or on connections with intermittent stability, add retries and delay parameters to package installation tasks. Apt operations failing mid-download due to timeout can leave the package manager in a locked state requiring manual intervention.
- name: Install PHP packages with retry logic
ansible.builtin.apt:
name: "{{ php_packages }}"
state: present
register: php_install_result
retries: 3
delay: 10
until: php_install_result is succeeded This pattern has saved multiple deployments where transient network issues would otherwise halt automation entirely. Combined with proper error handling and rollback strategies, it makes Ansible resilient enough for real-world conditions outside ideal datacenter environments.
Start Automating Server Setup with Ansible Playbooks Today
The transition from manual server configuration to automated infrastructure pays dividends immediately. Begin with a single base playbook covering security hardening and user management, then incrementally add roles for your application stack. Version control your playbooks alongside application code, treat infrastructure changes with the same review process as feature branches, and test idempotency rigorously. When you automate server setup with Ansible playbooks, you're not just saving time—you're building institutional knowledge that survives personnel changes and scales with your business.
If you're managing multiple Laravel applications, legal-tech portals, or eCommerce platforms and need help designing an Ansible strategy that fits your actual operational constraints, reach out to discuss your infrastructure needs. I regularly help teams in Nepal and worldwide move from fragile manual setups to reliable, reproducible server automation.

