DEV Community

ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on • Originally published at actocodes.hashnode.dev

DRY Infrastructure: How to Architect Modular Ansible Playbooks for Next.js

Infrastructure code suffers from the same antipatterns as application code, duplication, tight coupling, undocumented assumptions, and a growing resistance to change. A playbook written for one environment gets copy-pasted for the next, variables get hardcoded, and six months later you have five slightly different versions of the same deployment logic, each drifting independently and none of them trustworthy.

The DRY (Don't Repeat Yourself) principle applies to infrastructure as directly as it does to software. In Ansible, DRY infrastructure means roles for reusable task logic, variable hierarchies for environment-specific overrides, handlers for event-driven operations, and a playbook composition pattern that assembles production-grade deployments from interchangeable, independently testable units.

For Next.js, with its hybrid rendering modes, standalone server output, static asset optimization, and Node.js runtime requirements, a modular Ansible approach gives you a repeatable, environment-aware provisioning and deployment pipeline that works consistently from local staging to production at scale.

This guide covers how to design and implement a modular Ansible architecture for Next.js from the ground up.

Why Ansible for Next.js Infrastructure

Before diving into structure, it's worth addressing why Ansible fits Next.js provisioning well, especially alongside more modern IaC tools:

Ansible excels at configuration management and application deployment, the layer between raw infrastructure (where Terraform lives) and application orchestration (where Kubernetes lives). For teams running Next.js on VMs, bare metal, or alongside a lightweight container setup, Ansible bridges that gap cleanly.

Specifically for Next.js:

  • Node.js runtime management (version pinning, nvm, process managers)
  • Build artifact deployment (transferring .next/standalone output, managing static assets to CDN)
  • Nginx reverse proxy configuration (SSL termination, caching headers, compression)
  • Environment variable injection (.env files templated from Vault or group vars)
  • PM2 or systemd process management (zero-downtime restarts, health checks)
  • Log rotation and monitoring agent configuration

This is precisely where Ansible's agentless, SSH-based, idempotent task execution shines.

Step 1 - Project Structure for Modular Playbooks

Start with a structure that enforces separation of concerns from day one:

/ansible
  /inventories
    /staging
      hosts.yml
      group_vars/
        all.yml
        nextjs_servers.yml
    /production
      hosts.yml
      group_vars/
        all.yml
        nextjs_servers.yml
  /roles
    /common           ← base system configuration
    /nodejs           ← Node.js runtime installation and management
    /nextjs-app       ← Next.js application deployment
    /nginx            ← Nginx configuration and SSL
    /pm2              ← PM2 process management
    /monitoring       ← monitoring agent setup
    /logrotate        ← log rotation configuration
  /playbooks
    site.yml          ← master playbook (composes all roles)
    provision.yml     ← first-time server provisioning
    deploy.yml        ← application deployment only
    rollback.yml      ← rollback to previous release
  /group_vars
    all.yml           ← variables shared across all environments
  ansible.cfg
Enter fullscreen mode Exit fullscreen mode

This structure reflects three key design decisions:

Separate provisioning from deployment. provision.yml runs once (or rarely) to set up a server. deploy.yml runs on every release. Conflating them means every deployment re-runs expensive provisioning tasks unnecessarily.

Separate inventories per environment. Staging and production are entirely isolated inventory trees, different hosts, different group vars, no risk of accidentally running a production playbook against staging or vice versa.

Roles are the unit of reuse. Every discrete concern is a role. Roles are independently versioned, testable with Molecule, and composable in any playbook.

Step 2 - Ansible Configuration

# ansible.cfg
[defaults]
inventory          = ./inventories
roles_path         = ./roles
host_key_checking  = False
retry_files_enabled = False
stdout_callback    = yaml
interpreter_python = auto_silent
forks              = 10

[ssh_connection]
ssh_args           = -o ControlMaster=auto -o ControlPersist=60s
pipelining         = True   # significant performance improvement
Enter fullscreen mode Exit fullscreen mode

pipelining = True reduces the number of SSH connections per task, one of the most impactful performance improvements for large fleets. It requires requiretty to be disabled in sudoers on your target hosts.

Step 3 - Variable Hierarchy and Environment Overlays

Ansible's variable precedence is the mechanism for DRY environment-specific configuration. Define defaults at the broadest scope, override only what differs per environment:

# group_vars/all.yml - shared across ALL environments
nodejs_version: "20.11.0"
app_user: "nextjs"
app_group: "nextjs"
app_base_dir: "/opt/nextjs"
releases_to_keep: 5
pm2_instances: "max"
nginx_worker_processes: "auto"
Enter fullscreen mode Exit fullscreen mode
# inventories/staging/group_vars/nextjs_servers.yml - staging overrides
app_environment: "staging"
app_port: 3000
next_public_api_url: "https://api.staging.your-domain.com"
pm2_instances: 2                  # fewer instances on smaller staging servers
nginx_ssl_certificate: "/etc/letsencrypt/live/staging.your-domain.com/fullchain.pem"
nginx_ssl_key: "/etc/letsencrypt/live/staging.your-domain.com/privkey.pem"
app_releases_dir: "{{ app_base_dir }}/releases"
Enter fullscreen mode Exit fullscreen mode
# inventories/production/group_vars/nextjs_servers.yml - production overrides
app_environment: "production"
app_port: 3000
next_public_api_url: "https://api.your-domain.com"
pm2_instances: "max"              # use all available CPU cores
nginx_ssl_certificate: "/etc/letsencrypt/live/your-domain.com/fullchain.pem"
nginx_ssl_key: "/etc/letsencrypt/live/your-domain.com/privkey.pem"
app_releases_dir: "{{ app_base_dir }}/releases"
Enter fullscreen mode Exit fullscreen mode

Sensitive values (database URLs, API keys, secrets) should never appear in group vars. Use Ansible Vault for sensitive variable encryption or integrate with HashiCorp Vault for dynamic secret injection, as covered in our guide on replacing hardcoded credentials with Vault and GitHub OIDC.

Step 4 - The Node.js Role

The Node.js role manages runtime installation via nvm, ensuring version pinning and easy upgrades without system package manager conflicts:

roles/nodejs/
  tasks/
    main.yml
  defaults/
    main.yml
  handlers/
    main.yml
Enter fullscreen mode Exit fullscreen mode
# roles/nodejs/defaults/main.yml
nodejs_version: "20.11.0"
nvm_version: "0.39.7"
nvm_dir: "/home/{{ app_user }}/.nvm"
npm_global_packages:
  - pm2
  - typescript
Enter fullscreen mode Exit fullscreen mode
# roles/nodejs/tasks/main.yml
---
- name: Create application user
  ansible.builtin.user:
    name: "{{ app_user }}"
    group: "{{ app_group }}"
    system: true
    shell: /bin/bash
    home: "/home/{{ app_user }}"
    create_home: true
  tags: [nodejs, user]

- name: Check if nvm is installed
  ansible.builtin.stat:
    path: "{{ nvm_dir }}/nvm.sh"
  register: nvm_stat
  tags: [nodejs, nvm]

- name: Install nvm
  ansible.builtin.shell:
    cmd: >
      curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v{{ nvm_version }}/install.sh | bash
  become: true
  become_user: "{{ app_user }}"
  when: not nvm_stat.stat.exists
  tags: [nodejs, nvm]

- name: Install Node.js via nvm
  ansible.builtin.shell:
    cmd: >
      source {{ nvm_dir }}/nvm.sh &&
      nvm install {{ nodejs_version }} &&
      nvm alias default {{ nodejs_version }}
  args:
    executable: /bin/bash
  become: true
  become_user: "{{ app_user }}"
  tags: [nodejs]

- name: Install global npm packages
  ansible.builtin.shell:
    cmd: >
      source {{ nvm_dir }}/nvm.sh &&
      npm install -g {{ item }}
  args:
    executable: /bin/bash
  become: true
  become_user: "{{ app_user }}"
  loop: "{{ npm_global_packages }}"
  tags: [nodejs, npm]
Enter fullscreen mode Exit fullscreen mode

Step 5 - The Next.js Application Role

The Next.js role implements a release directory pattern - the key mechanism for zero-downtime deployments and fast rollbacks:

roles/nextjs-app/
  tasks/
    main.yml
    deploy.yml
    rollback.yml
    cleanup.yml
  templates/
    env.j2            ← .env.local template
    pm2.config.js.j2  ← PM2 ecosystem config template
  handlers/
    main.yml
  defaults/
    main.yml
Enter fullscreen mode Exit fullscreen mode
# roles/nextjs-app/tasks/deploy.yml
- name: Generate release timestamp
  ansible.builtin.set_fact:
    release_timestamp: "{{ ansible_date_time.epoch }}"
  tags: [deploy]

- name: Create release directory
  ansible.builtin.file:
    path: "{{ app_releases_dir }}/{{ release_timestamp }}"
    state: directory
    owner: "{{ app_user }}"
    group: "{{ app_group }}"
    mode: "0755"
  tags: [deploy]

- name: Upload Next.js standalone build
  ansible.builtin.synchronize:
    src: "{{ local_build_path }}/.next/standalone/"
    dest: "{{ app_releases_dir }}/{{ release_timestamp }}"
    delete: true
    rsync_opts:
      - "--exclude=.git"
  tags: [deploy]

- name: Upload static assets
  ansible.builtin.synchronize:
    src: "{{ local_build_path }}/.next/static/"
    dest: "{{ app_releases_dir }}/{{ release_timestamp }}/.next/static/"
  tags: [deploy]

- name: Template environment file
  ansible.builtin.template:
    src: env.j2
    dest: "{{ app_releases_dir }}/{{ release_timestamp }}/.env.local"
    owner: "{{ app_user }}"
    group: "{{ app_group }}"
    mode: "0640"           # restrict read to app user only
  tags: [deploy]

- name: Update current symlink atomically
  ansible.builtin.file:
    src: "{{ app_releases_dir }}/{{ release_timestamp }}"
    dest: "{{ app_base_dir }}/current"
    state: link
    owner: "{{ app_user }}"
    group: "{{ app_group }}"
  notify: Reload PM2
  tags: [deploy]
Enter fullscreen mode Exit fullscreen mode

The atomic symlink swap (current → new release) is the mechanism that makes zero-downtime deployments possible. PM2 reloads against the new current directory; if the reload fails, the symlink points back to the previous release.

# roles/nextjs-app/tasks/rollback.yml
- name: List available releases
  ansible.builtin.find:
    paths: "{{ app_releases_dir }}"
    file_type: directory
  register: available_releases
  tags: [rollback]

- name: Identify previous release
  ansible.builtin.set_fact:
    previous_release: >-
      {{
        available_releases.files
        | sort(attribute='mtime', reverse=True)
        | map(attribute='path')
        | list
        | nth(1)
      }}
  tags: [rollback]

- name: Rollback symlink to previous release
  ansible.builtin.file:
    src: "{{ previous_release }}"
    dest: "{{ app_base_dir }}/current"
    state: link
  notify: Reload PM2
  tags: [rollback]

- name: Report rollback target
  ansible.builtin.debug:
    msg: "Rolled back to: {{ previous_release }}"
  tags: [rollback]
Enter fullscreen mode Exit fullscreen mode

Step 6 - The Nginx Role

# roles/nginx/templates/nextjs.conf.j2
upstream nextjs_upstream {
  server 127.0.0.1:{{ app_port }};
  keepalive 64;
}

server {
  listen 80;
  server_name {{ nginx_server_name }};
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name {{ nginx_server_name }};

  ssl_certificate     {{ nginx_ssl_certificate }};
  ssl_certificate_key {{ nginx_ssl_key }};
  ssl_protocols       TLSv1.2 TLSv1.3;
  ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
  ssl_prefer_server_ciphers off;

  # Next.js static assets, long-lived cache with content hashing
  location /_next/static/ {
    alias {{ app_base_dir }}/current/.next/static/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  # Public assets
  location /public/ {
    alias {{ app_base_dir }}/current/public/;
    expires 30d;
    add_header Cache-Control "public";
  }

  # Next.js server
  location / {
    proxy_pass         http://nextjs_upstream;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade $http_upgrade;
    proxy_set_header   Connection 'upgrade';
    proxy_set_header   Host $host;
    proxy_set_header   X-Real-IP $remote_addr;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_cache_bypass $http_upgrade;
  }
}
Enter fullscreen mode Exit fullscreen mode
# roles/nginx/tasks/main.yml
- name: Install Nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
  tags: [nginx]

- name: Template Next.js site configuration
  ansible.builtin.template:
    src: nextjs.conf.j2
    dest: /etc/nginx/sites-available/nextjs
    owner: root
    group: root
    mode: "0644"
  notify: Reload Nginx
  tags: [nginx]

- name: Enable site
  ansible.builtin.file:
    src: /etc/nginx/sites-available/nextjs
    dest: /etc/nginx/sites-enabled/nextjs
    state: link
  notify: Reload Nginx
  tags: [nginx]
Enter fullscreen mode Exit fullscreen mode
# roles/nginx/handlers/main.yml
- name: Reload Nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded
  listen: Reload Nginx
Enter fullscreen mode Exit fullscreen mode

Step 7 - Composing Playbooks

With roles defined, playbooks become thin composition layers:

# playbooks/provision.yml - run once to set up new servers
- name: Provision Next.js servers
  hosts: nextjs_servers
  become: true
  roles:
    - role: common
      tags: [common]
    - role: nodejs
      tags: [nodejs]
    - role: nginx
      tags: [nginx]
    - role: pm2
      tags: [pm2]
    - role: logrotate
      tags: [logrotate]
    - role: monitoring
      tags: [monitoring]
Enter fullscreen mode Exit fullscreen mode
# playbooks/deploy.yml - run on every release
- name: Deploy Next.js application
  hosts: nextjs_servers
  become: true
  vars:
    local_build_path: "{{ lookup('env', 'NEXT_BUILD_PATH') }}"

  pre_tasks:
    - name: Validate build artifact exists
      ansible.builtin.stat:
        path: "{{ local_build_path }}/.next/standalone"
      delegate_to: localhost
      register: build_stat
      failed_when: not build_stat.stat.exists

  roles:
    - role: nextjs-app
      tags: [deploy]

  post_tasks:
    - name: Verify application is healthy
      ansible.builtin.uri:
        url: "https://{{ nginx_server_name }}/api/health"
        status_code: 200
        timeout: 30
      retries: 5
      delay: 10
      register: health_check
      until: health_check.status == 200
Enter fullscreen mode Exit fullscreen mode
# playbooks/rollback.yml
- name: Rollback Next.js to previous release
  hosts: nextjs_servers
  become: true
  tasks:
    - name: Execute rollback
      ansible.builtin.include_role:
        name: nextjs-app
        tasks_from: rollback
Enter fullscreen mode Exit fullscreen mode

Step 8 - CI/CD Integration

# .github/workflows/deploy-nextjs.yml
name: Deploy Next.js

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install and build
        run: |
          npm ci
          npm run build

      - name: Install Ansible
        run: pip install ansible ansible-lint --break-system-packages

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          echo "StrictHostKeyChecking no" >> ~/.ssh/config

      - name: Deploy to production
        run: |
          ansible-playbook playbooks/deploy.yml \
            -i inventories/production/hosts.yml \
            --private-key ~/.ssh/deploy_key \
            -e "NEXT_BUILD_PATH=${{ github.workspace }}"
        env:
          ANSIBLE_HOST_KEY_CHECKING: "False"
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

Using shell tasks where native modules exist. ansible.builtin.shell bypasses idempotency, it runs every time regardless of whether the state has changed. Always prefer native modules (apt, copy, template, service) and fall back to shell only when no native module covers the task.

Hardcoding environment-specific values in roles. Role defaults should be genuinely sensible defaults, not production values masquerading as defaults. Production specifics belong in inventory group_vars.

Not cleaning up old releases. The release directory pattern accumulates historical deployments. Add a cleanup task to retain only {{ releases_to_keep }} releases, the rest is disk usage with no operational value.

Skipping the health check post-task. A deploy playbook that doesn't verify the application is healthy after deployment is incomplete. The health check is the difference between a successful deploy and an undetected outage.

Conclusion

Modular Ansible playbooks are not just a style preference, they are an operational necessity at any scale beyond a single server. Roles as units of reuse, variable hierarchies for environment overlays, the release directory pattern for zero-downtime deployments, and thin playbooks for composition give you an infrastructure codebase that is maintainable, testable, and genuinely DRY.

For Next.js specifically, this architecture handles the full lifecycle, provisioning the Node.js runtime, deploying standalone build artifacts, configuring Nginx for optimal caching of static assets, managing PM2 for process reliability, and providing a one-command rollback when things go wrong.

Infrastructure code that can't be understood, changed, and trusted by the team that operates it is a liability. Infrastructure code built to the same quality standards as your application code is a competitive advantage.

Top comments (0)