Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Automate Server Setup with Ansible Playbooks

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.

Recommended Ansible Project Structureinventory/group_vars/roles/site.ymlproduction.iniall.yml (secrets)common/php-fpm/nginx/mysql/Host definitions & SSH configEncrypted variables per environmentBase security, users, timezonePHP 8.4 + FPM pool configsVhost templates + SSL certsDatabase + user provisioning
Standard Ansible directory layout for managing multiple Laravel and WordPress servers with isolated roles and encrypted secrets.

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.

  1. Always use fully qualified collection names (e.g., ansible.builtin.apt) to avoid ambiguity and future deprecation warnings.
  2. Prefer declarative modules over shell or command; reserve raw commands for operations with no native module support.
  3. Test idempotency by running the playbook twice consecutively; the second run should report zero changes.
  4. Use --check --diff flags 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.

PHP-FPM + Nginx Role Execution FlowInstall PHP 8.4+ FPM + extensionsTemplate Pool Configwww.conf.j2 → /etc/php/Restart PHP-FPMHandler (if changed)Install Nginx+ CertbotTemplate Vhostlaravel.conf.j2Obtain SSL Certcertbot --nginxReload NginxHandler (if changed)Key Variables (group_vars)php_version: "8.4"php_memory_limit: "256M"php_max_children: 20nginx_server_name: "example.com"nginx_root: "/var/www/app/public"ssl_enabled: truessl_email: "admin@example.com"Templates reference these variablesso the same role works acrossstaging, production, and clients.⚠ Never hardcode domains or pathsinside role templates.
Ansible role execution flow for PHP 8.4 FPM and Nginx with templated configuration driven by group variables.

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.

CriteriaManual SSH SetupAnsibleTerraform + Cloud InitDocker / Containers
Initial Learning TimeNone (but error-prone)2–4 days for basics1–3 weeks1–2 weeks
Agent RequiredN/ANo (SSH only)No (API-based)Container runtime
IdempotencyManual disciplineBuilt-in by designBuilt-in (declarative)Image immutability
Best ForSingle throwaway serverMulti-server config mgmtCloud resource provisioningMicroservices / local dev
Secret ManagementAd-hoc / riskyAnsible Vault (integrated)External (Vault, SSM)Env vars / secrets mgr
Nepal VPS CompatibilityUniversalUniversal (SSH access)Requires cloud APIRequires container support
Typical Cost ImpactHigh (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.

Choosing Your Automation ApproachNew Project?1 server only?2+ servers?Manual + Document(or start with Ansible)Use AnsibleNeed cloud resources too?Ansible OnlyTerraform + Ansible(provision + configure)Yes →← NoNo →← Yes⚠ Even single-server projects benefit from Ansible if you value reproducibility and disaster recovery
Decision framework for selecting server automation tooling based on project scope and infrastructure requirements.

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 --diff before 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_vars and 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_string for 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 creates or removes parameters. 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_interpreter explicitly 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.

Frequently Asked Questions

Ansible is an agentless automation tool that configures servers via SSH using YAML playbooks. It eliminates manual setup errors and ensures consistent environments across development, staging, and production infrastructure.

Ansible provides idempotency, meaning running the same playbook twice produces identical results without side effects. Bash scripts lack this safety and require complex conditional logic to prevent duplicate package installations or configuration overwrites during repeated executions.

No. Ansible is agentless and connects via standard SSH. Target servers only need Python 3 and SSH access, making it ideal for managing Ubuntu 22/24 servers where installing additional daemons adds unnecessary complexity and security surface area.

Use a standard layout with inventory files separating environments, group_vars for environment-specific variables like PHP versions, roles for modular tasks like nginx or php-fpm, and a main site.yml orchestrator. This structure scales from single VPS deployments to multi-server clusters while keeping secrets out of version control through ansible-vault encryption.

Never store plaintext secrets in playbooks. Use ansible-vault to encrypt variable files containing API keys, database credentials, or SSL certificates. Decrypt them at runtime using --ask-vault-pass or a vault password file. On client projects, I keep vault passwords in local keychains, never in Git repositories, ensuring production credentials remain protected even if code is exposed.

Yes. Create a dedicated role that templates individual pool.conf files using Jinja2 variables for user, socket path, and process limits. The playbook iterates over a list of site definitions, generating unique pools per application. After updating configs, include a handler to reload php-fpm only when changes occur, preventing unnecessary service restarts during idempotent runs.

Template vhost files using Jinja2 with variables for server_name, root path, and PHP upstream socket. Validate syntax with nginx -t before reloading via a handler. In my experience deploying Laravel applications, always include SSL configuration using Let's Encrypt certificates managed by certbot, and test configurations in staging first to avoid taking production sites offline due to syntax errors.

Using shell or command modules instead of native Ansible modules breaks idempotency because Ansible cannot detect state changes. Always prefer apt over raw shell apt-get, template over copy for dynamic content, and service over systemd commands. Also avoid registering variables based on command output that changes every run, as this triggers false change detection and unnecessary handler executions.

Separate inventories by environment (dev, staging, production) and hosting provider. For Nepal clients using local datacenters versus AWS EC2, maintain distinct group_vars accounting for different network latencies, package mirror speeds, and timezone configurations. Include connection variables like ansible_ssh_private_key_file and ansible_python_interpreter per host group to handle heterogeneous server setups common in mixed-infrastructure deployments.

Yes. Create a role that installs certbot, obtains certificates via webroot or standalone validation, and configures automatic renewal through systemd timers or cron. Include handlers to reload nginx after renewal. On production legal-tech portals I maintain, playbooks verify certificate validity before deployment and alert on expiration warnings, ensuring HTTPS remains uninterrupted without manual intervention during routine maintenance windows.

Use the debug module strategically to inspect variable values before problematic tasks. Run with --start-at-task to resume from failure points without re-executing successful steps. Check failed task stderr messages carefully; they often reveal missing dependencies or permission issues. In practice, adding check_mode: true to risky tasks lets you preview changes safely before applying them to production servers.

Initial playbook development takes 8-16 hours for a standard Laravel stack, costing Rs 40,000-80,000 (USD 300-600) at senior developer rates. However, subsequent server provisioning drops from 4 hours to 15 minutes. For agencies managing multiple client sites, this pays for itself within three deployments while eliminating configuration drift and reducing onboarding time for new team members significantly.

Specify exact versions in apt tasks using package=php8.3-fpm=8.3.* syntax to prevent unexpected upgrades during routine maintenance. Pin critical packages in /etc/apt/preferences.d/ via template module. On production eCommerce systems, I always pin PHP, MySQL, and Redis versions matching tested application requirements, then schedule deliberate upgrade windows rather than risking breaking changes from unattended security updates.

Yes. Use Ansible for infrastructure provisioning and Deployer 7 for application deployment. Ansible prepares servers with correct PHP versions, extensions, directories, and permissions, while Deployer handles symlinked releases, asset building, and cache clearing. This separation keeps concerns distinct; infrastructure changes rarely, but deployments happen frequently. Several sister sites I maintain use this exact pattern on shared EC2 infrastructure.

Test playbooks against disposable Vagrant or Docker containers mirroring production OS versions before touching live servers. Use molecule for automated role testing with multiple scenarios. Implement CI pipelines that lint YAML syntax, validate Ansible best practices with ansible-lint, and run check-mode tests. In my experience, catching template rendering errors or missing variables in CI prevents embarrassing production outages during late-night maintenance windows.

Share this article

Quick Contact Options
Choose how you want to connect me: