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/standaloneoutput, managing static assets to CDN) - Nginx reverse proxy configuration (SSL termination, caching headers, compression)
-
Environment variable injection (
.envfiles 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
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
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"
# 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"
# 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"
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
# 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
# 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]
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
# 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]
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]
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;
}
}
# 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]
# roles/nginx/handlers/main.yml
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: Reload Nginx
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]
# 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
# 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
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"
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)