Published on

Automate Next.js Deployment to AWS EC2 with Ansible

Authors
  • avatar
    Name
    Nguyen Phuc Cuong

The Problem: Manual Deployment Hell

Picture this: You've built an amazing Next.js app. Your users love it. But every time you want to deploy a new feature, you have to:

  1. SSH into your server
  2. Pull the latest code
  3. Run npm install and npm run build
  4. Restart your app
  5. Pray nothing breaks

In my company, our maintainer had to update apps one by one across multiple servers. It was slow, error-prone, and frankly... boring.

I thought to myself: "Can I automate this process?"

The answer? Yes! And that's where Ansible comes in.

What is Ansible? (Explained for JavaScript Developers)

Think of Ansible like package.json scripts, but for servers instead of your local machine.

Instead of running:

npm run build
npm start

You write an Ansible "playbook" that does this across multiple servers automatically:

- name: Build and deploy Next.js app
  hosts: all
  tasks:
    - name: Install dependencies
      npm: path=/home/app
    - name: Build app
      command: npm run build
    - name: Start with PM2
      command: pm2 start ecosystem.config.js

The magic? One command deploys to 1 server or 100 servers. Same process, zero headaches.

Our Deployment Architecture

Here's what we're building:

Automated Deployment Flow

GitHub Actions

Triggers on push
to main branch

Ansible Controller

EC2 Instance
(Amazon Linux 2)

App Servers

1+ EC2 Instances
(Your Next.js app)

Result: Push code → Automatic deployment to all servers ✨

Why this setup?

  • GitHub Actions: Free CI/CD (if you're already using GitHub)
  • Ansible Controller: One place to manage all deployments
  • Multiple App Servers: Scale easily by adding more EC2 instances

Step 1: Create Your Ansible Controller (EC2)

First, let's set up our "command center" — an EC2 instance that will run Ansible.

Launch EC2 Instance

  1. AMI: Amazon Linux 2 (free tier eligible)
  2. Instance Type: t2.micro (free tier)
  3. Security Group: Allow SSH (port 22) from your IP
  4. Key Pair: Create or use existing (you'll need this!)

Install Ansible

SSH into your controller and run:

sudo yum update -y
sudo yum install -y python3-pip
pip3 install ansible --user

# Add to PATH
echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

# Verify installation
ansible --version

💡 Pro Tip: Save your SSH key (.pem file) securely — you'll need it for GitHub Actions later!

Step 2: Create Your App Server(s)

Now let's create the EC2 instance(s) where your Next.js app will run.

Launch App Server EC2

  1. AMI: Amazon Linux 2023 (newer, better performance)
  2. Instance Type: t3.micro or larger (depending on your app)
  3. Security Group:
    • SSH (port 22) from Ansible Controller
    • HTTP (port 3000) from anywhere (or your Load Balancer)
  4. Key Pair: Same as your Ansible Controller

Test SSH Connection

From your Ansible Controller, test that you can reach your app server:

ssh -i ~/.ssh/your-key.pem ec2-user@YOUR-APP-SERVER-IP

If this works, you're ready for the next step!

Step 3: Create the Ansible Playbook

This is where the magic happens. Create a file called deploy.yml:

- name: Deploy Next.js app to EC2
  hosts: nextjs_servers
  gather_facts: yes
  become: yes
  vars:
    app_dir: /home/ec2-user/app
    app_owner: ec2-user
    app_group: ec2-user
    app_repo: https://github.com/mrdaiking/test_ansible.git
    app_branch: main
    app_subdir: my-nextjs-app
  tasks:
    - name: Check if Node.js is installed
      ansible.builtin.command: node -v
      register: node_check
      ignore_errors: true
      changed_when: false

    - name: Ensure prerequisites are installed on Amazon/RedHat (dnf)
      when: (ansible_facts.os_family | lower) in ["redhat"] or (ansible_facts.distribution == 'Amazon')
      ansible.builtin.dnf:
        name:
          - git
          - ca-certificates
        state: present

    - name: Ensure prerequisites are installed on Debian/Ubuntu (apt)
      when: (ansible_facts.os_family | lower) in ["debian"]
      ansible.builtin.apt:
        name:
          - git
          - curl
          - ca-certificates
        state: present
        update_cache: yes

    - name: Ensure application directory exists
      ansible.builtin.file:
        path: "{{ app_dir }}"
        state: directory
        owner: "{{ app_owner }}"
        group: "{{ app_group }}"
        mode: "0755"

    - name: Checkout application repository
      ansible.builtin.git:
        repo: "{{ app_repo }}"
        dest: "{{ app_dir }}"
        version: "{{ app_branch }}"
        force: yes
        update: yes
      become_user: "{{ app_owner }}"

    - name: Determine if app_subdir exists
      ansible.builtin.stat:
        path: "{{ app_dir }}/{{ app_subdir }}"
      register: app_subdir_path

    - name: Set working_dir
      ansible.builtin.set_fact:
        working_dir: "{{ app_dir ~ '/' ~ app_subdir if (app_subdir_path.stat.isdir | default(false)) else app_dir }}"

    - name: Install Node.js 18.x on Amazon Linux 2023
      when: ansible_facts.distribution == 'Amazon' and ansible_facts.distribution_major_version == '2023' and (node_check.rc is defined and node_check.rc != 0)
      ansible.builtin.shell: |
        set -e
        sudo dnf -y install nodejs
      args:
        executable: /bin/bash

    - name: Install Node.js 18.x via NodeSource on Debian/Ubuntu
      when: (ansible_facts.os_family | lower) in ["debian"] and (node_check.rc is defined and node_check.rc != 0)
      ansible.builtin.shell: |
        set -e
        curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
        sudo apt-get install -y nodejs
      args:
        executable: /bin/bash

    - name: Check if pm2 is installed
      ansible.builtin.command: pm2 -v
      register: pm2_check
      ignore_errors: true
      changed_when: false

    - name: Ensure pm2 is installed globally
      when: pm2_check.rc is defined and pm2_check.rc != 0
      ansible.builtin.shell: |
        set -e
        sudo npm install -g pm2
      args:
        executable: /bin/bash

    - name: Check if any swap is active
      ansible.builtin.command: swapon --noheadings --show=NAME
      register: swap_status
      ignore_errors: true
      changed_when: false

    - name: Ensure 2G swapfile exists and is active (to avoid OOM during npm install)
      when: (swap_status.stdout | trim) == ""
      block:
        - name: Allocate swapfile
          ansible.builtin.command: fallocate -l 2G /swapfile
          args:
            creates: /swapfile
        - name: Set swapfile permissions
          ansible.builtin.file:
            path: /swapfile
            mode: "0600"
        - name: Format swapfile
          ansible.builtin.command: mkswap /swapfile
        - name: Enable swapfile
          ansible.builtin.command: swapon /swapfile
        - name: Persist swapfile in fstab
          ansible.builtin.mount:
            name: none
            src: /swapfile
            fstype: swap
            opts: sw
            state: present

    - name: Install dependencies (npm ci if lockfile exists)
      become_user: "{{ app_owner }}"
      ansible.builtin.shell: |
        set -e
        if [ -f package-lock.json ]; then
          npm ci --no-audit --no-fund --prefer-offline
        else
          npm install --no-audit --no-fund --prefer-offline
        fi
      args:
        chdir: "{{ working_dir }}"
        executable: /bin/bash
      environment:
        NODE_OPTIONS: "--max-old-space-size=512"

    - name: Build Next.js app
      become_user: "{{ app_owner }}"
      ansible.builtin.shell: |
        set -e
        npm run build
      args:
        chdir: "{{ working_dir }}"
        executable: /bin/bash

    - name: Start or restart app with PM2
      become_user: "{{ app_owner }}"
      ansible.builtin.shell: |
        set -e
        pm2 start npm --name "nextjs-app" -- run start || pm2 restart nextjs-app
        pm2 save
      args:
        chdir: "{{ working_dir }}"
        executable: /bin/bash

Create Inventory File

Create inventory.ini:

[app_servers]
your-app-server-ip ansible_user=ec2-user ansible_ssh_private_key_file=~/.ssh/your-key.pem

🎯 What this playbook does:
✅ Installs Node.js and PM2
✅ Clones your latest code
✅ Installs dependencies safely
✅ Builds your Next.js app
✅ Starts it with PM2 (keeps running even if SSH disconnects)
✅ Health checks your app is working

Step 4: Test Your Playbook Locally

Before automation, let's make sure everything works:

# Test connection
ansible all -i inventory.ini -m ping

# Run the full deployment
ansible-playbook -i inventory.ini deploy.yml

If everything works, you should see:

  • ✅ All tasks completed successfully
  • ✅ Your Next.js app running on http://your-server-ip:3000

Step 5: Automate with GitHub Actions

Now for the automation magic! Create .github/workflows/deploy.yml in your Next.js repository:

# =============================================================================
# Deploy Next.js App to EC2 using Ansible
# =============================================================================
# This workflow runs when you push to the main branch and deploys your app
# using a two-host setup: Control Host (runs Ansible) -> App Host (runs your app)

name: Deploy with Ansible

# Trigger: Run when code is pushed to main branch
on:
  push:
    branches: [main]
  # Optional: Allow manual runs from GitHub Actions tab
  workflow_dispatch: {}

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
      # =============================================================================
      # STEP 1: Get the code
      # =============================================================================
      - name: 📥 Checkout repository code
        uses: actions/checkout@v4

      # =============================================================================
      # STEP 2: Setup SSH authentication
      # =============================================================================
      - name: 🔐 Setup SSH key for server access
        shell: bash
        run: |
          set -euo pipefail  # Exit on any error
          umask 077          # Secure file permissions
          
          # Create SSH directory
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          
          echo "🔧 Processing SSH private key from GitHub secrets..."
          
          # Create temporary file for the key
          TMP_KEY=$(mktemp)
          printf "%s" "${{ secrets.ANSIBLE_SSH_PRIVATE_KEY }}" > "$TMP_KEY"
          
          # Handle different key formats that might be in the secret
          # Some people copy keys with literal \n instead of real newlines
          if grep -q "\\\\n" "$TMP_KEY"; then
            echo "🔧 Converting escaped newlines to real newlines..."
            sed 's/\\\\n/\n/g' "$TMP_KEY" > "$TMP_KEY.conv"
            mv "$TMP_KEY.conv" "$TMP_KEY"
          fi
          
          # Remove Windows line endings if present
          tr -d '\r' < "$TMP_KEY" > "$TMP_KEY.nocr"
          mv "$TMP_KEY.nocr" "$TMP_KEY"
          
          # Check if it's already a PEM file, if not try base64 decoding
          if grep -q "BEGIN .*PRIVATE KEY" "$TMP_KEY"; then
            echo "✅ Found PEM format key"
            cp "$TMP_KEY" ~/.ssh/id_rsa
          else
            echo "🔧 Trying to decode as base64..."
            if base64 -d "$TMP_KEY" > ~/.ssh/id_rsa 2>/dev/null; then
              echo "✅ Successfully decoded base64 key"
            else
              echo "❌ Key is neither PEM nor valid base64!" >&2
              echo "Please check your ANSIBLE_SSH_PRIVATE_KEY secret" >&2
              exit 1
            fi
          fi
          
          # Set correct permissions (SSH requires this)
          chmod 600 ~/.ssh/id_rsa
          
          # Validate the key works
          if ! ssh-keygen -y -f ~/.ssh/id_rsa >/dev/null 2>&1; then
            echo "❌ SSH key is invalid or encrypted!" >&2
            echo "Make sure your key is unencrypted (no passphrase)" >&2
            exit 1
          fi
          
          echo "✅ SSH key validated successfully"
          
          # Start SSH agent and add the key
          eval "$(ssh-agent -s)"
          ssh-add ~/.ssh/id_rsa
          
          # Clean up
          rm -f "$TMP_KEY"
          echo "🔐 SSH authentication setup complete!"

      # =============================================================================
      # STEP 3: Prepare SSH host keys (security)
      # =============================================================================  
      - name: 🔒 Add server fingerprints to known hosts
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          
          echo "🔒 Adding server fingerprints to prevent SSH warnings..."
          
          # Add both control host and app host to known_hosts
          for host in "${{ secrets.ANSIBLE_HOST }}" "${{ secrets.ANSIBLE_APP_HOST }}"; do
            if [[ -n "$host" ]]; then
              echo "Adding fingerprint for: $host"
              ssh-keyscan -H "$host" >> ~/.ssh/known_hosts 2>/dev/null || true
            fi
          done
          
          echo "✅ Host fingerprints added"

      # =============================================================================
      # STEP 4: Network connectivity check
      # =============================================================================
      - name: 🌐 Check network connectivity
        shell: bash
        run: |
          set -euo pipefail
          
          # Show the runner's public IP (helpful for Security Group rules)
          echo "🌐 GitHub Runner public IP:" $(curl -fsSL https://checkip.amazonaws.com || echo "unknown")
          echo "📋 Target servers:"
          echo "   Control host (Ansible): ${{ secrets.ANSIBLE_HOST }}"
          echo "   App host (Next.js):     ${{ secrets.ANSIBLE_APP_HOST }}"
          
          # Test if we can reach the control host
          HOST="${{ secrets.ANSIBLE_HOST }}"
          echo ""
          echo "🔌 Testing connection to control host: ${HOST}:22..."
          
          if command -v nc >/dev/null 2>&1; then
            nc -vz -w 5 "$HOST" 22 || {
              echo "❌ Cannot reach ${HOST}:22 from GitHub runner!" >&2
              echo "💡 Check your EC2 Security Group allows inbound port 22 from GitHub's IPs" >&2
              exit 1
            }
          else
            timeout 6 bash -c "</dev/tcp/${HOST}/22" 2>/dev/null || {
              echo "❌ Cannot reach ${HOST}:22 from GitHub runner!" >&2
              echo "💡 Check your EC2 Security Group allows inbound port 22 from GitHub's IPs" >&2
              exit 1
            }
          fi
          
          echo "✅ Connection test passed!"

      # =============================================================================
      # STEP 5: Deploy the application using Ansible
      # =============================================================================
      - name: 🚀 Deploy Next.js app via Ansible
        shell: bash
        run: |
          set -euo pipefail
          
          echo "🚀 Starting deployment process..."
          echo ""
          echo "📋 Deployment Architecture:"
          echo "   GitHub Runner → Control Host (AL2) → App Host (AL2023) → Next.js App"
          echo ""
          
          # Configuration
          USER_TO_USE="ec2-user"
          CONTROL_HOST="${{ secrets.ANSIBLE_HOST }}"
          APP_HOST="${{ secrets.ANSIBLE_APP_HOST }}"
          
          # SSH options to prevent timeouts during long operations
          SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=120 -o TCPKeepAlive=yes"
          
          echo "🏗️  Step 1: Preparing workspace on control host..."
          # Create a working directory on the control host
          ssh ${SSH_OPTS} "${USER_TO_USE}@${CONTROL_HOST}" \
            "sudo install -d -m 755 -o ${USER_TO_USE} -g ${USER_TO_USE} /home/${USER_TO_USE}/deploy"
          
          # Ensure SSH directory exists (the control host needs to SSH to the app host)
          ssh ${SSH_OPTS} "${USER_TO_USE}@${CONTROL_HOST}" \
            "install -d -m 700 /home/${USER_TO_USE}/.ssh"
          
          echo "📤 Step 2: Uploading Ansible playbook..."
          # Upload our playbook to the control host
          scp ${SSH_OPTS} ansible/deploy.yml "${USER_TO_USE}@${CONTROL_HOST}:/home/${USER_TO_USE}/deploy/deploy.yml"
          
          echo "📝 Step 3: Creating Ansible inventory..."
          # Create an inventory file that tells Ansible which servers to target
          INVENTORY=$(mktemp)
          cat > "$INVENTORY" <<'INV'
          [nextjs_servers]
          app ansible_host=${{ secrets.ANSIBLE_APP_HOST }} ansible_user=ec2-user ansible_ssh_private_key_file=/home/ec2-user/.ssh/your_key.pem ansible_ssh_common_args='-o StrictHostKeyChecking=no'
          INV
          
          scp ${SSH_OPTS} "$INVENTORY" "${USER_TO_USE}@${CONTROL_HOST}:/home/${USER_TO_USE}/deploy/inventory.ini"
          rm -f "$INVENTORY"
          
          echo "🔧 Step 4: Installing Ansible on control host (if needed)..."
          # Make sure Ansible is installed on the control host
          ssh ${SSH_OPTS} "${USER_TO_USE}@${CONTROL_HOST}" '
            set -euo pipefail
            
            if ! command -v ansible-playbook >/dev/null 2>&1; then
              echo "📦 Installing Ansible..."
              
              if command -v yum >/dev/null 2>&1; then
                # Amazon Linux 2 path
                sudo yum -y install python3-pip || sudo yum -y install python3
                python3 -m pip install --user ansible
                echo "export PATH=\"$HOME/.local/bin:$PATH\"" >> ~/.bashrc
              elif command -v dnf >/dev/null 2>&1; then
                # Amazon Linux 2023 path
                sudo dnf -y install ansible
              fi
              
              echo "✅ Ansible installed successfully"
            else
              echo "✅ Ansible already installed"
            fi
          '
          
          echo ""
          echo "🎯 Step 5: Running Ansible playbook on app host..."
          echo "   This will install dependencies, build your app, and start it with PM2"
          echo ""
          
          # Run the actual deployment
          ssh ${SSH_OPTS} "${USER_TO_USE}@${CONTROL_HOST}" "
            # Try to find ansible-playbook in different possible locations
            if command -v ansible-playbook >/dev/null 2>&1; then
              ansible-playbook -i /home/${USER_TO_USE}/deploy/inventory.ini /home/${USER_TO_USE}/deploy/deploy.yml
            elif [ -x \"\$HOME/.local/bin/ansible-playbook\" ]; then
              \"\$HOME/.local/bin/ansible-playbook\" -i /home/${USER_TO_USER}/deploy/inventory.ini /home/${USER_TO_USE}/deploy/deploy.yml
            else
              PATH=\"\$HOME/.local/bin:\$PATH\" ansible-playbook -i /home/${USER_TO_USE}/deploy/inventory.ini /home/${USER_TO_USE}/deploy/deploy.yml
            fi
          "
          
          echo ""
          echo "🎉 Deployment completed successfully!"
          echo "🌐 Your Next.js app should now be running on the app host at port 3000"

Add GitHub Secrets

In your GitHub repo, go to Settings → Secrets → Actions and add:

  1. ANSIBLE_SSH_PRIVATE_KEY: Your .pem file content (the whole file!)
  2. ANSIBLE_HOST: Your Ansible Controller's public IP
  3. ANSIBLE_APP_HOST: Your app server's IP (for the inventory file)

⚠️ Important: Never commit SSH keys to your repository. Always use GitHub Secrets!

Step 6: Deploy and Celebrate! 🎉

Now comes the moment of truth:

  1. Commit and push your changes to the main branch
  2. Watch GitHub Actions run your workflow
  3. Visit your app at http://your-server-ip:3000

If everything worked, you'll see your Next.js app running!

Troubleshooting Common Issues

"SSH Connection Refused"

  • Check Security Groups allow port 22
  • Verify your SSH key is correct
  • Test manual SSH connection first

"npm install fails with error 137"

This is an out-of-memory error. Add a swapfile to your playbook:

- name: Create swapfile
  become: yes
  command: |
    fallocate -l 2G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile
    swapon /swapfile
  ignore_errors: yes

"Playbook not found"

Make sure your file paths in the GitHub Actions workflow match your actual file structure.

Going Beyond: Scale Like a Pro

Deploy to Multiple Servers

Add more servers to your inventory.ini:

[app_servers]
app-server-1 ansible_user=ec2-user
app-server-2 ansible_user=ec2-user  
app-server-3 ansible_user=ec2-user

One command now deploys to all servers! 🚀

Add a Load Balancer

Use AWS Application Load Balancer to distribute traffic across your servers.

Environment Variables

Add environment-specific configs to your playbook:

- name: Create .env file
  copy:
    content: |
      NODE_ENV=production
      DATABASE_URL={{ database_url }}
      API_KEY={{ api_key }}
    dest: "{{ app_dir }}/.env"

What's Next?

This setup gives you a solid foundation, but there's always room to grow:

  • Docker + ECS/EKS for container-based deployments
  • Blue-Green deployments for zero-downtime updates
  • Monitoring with tools like New Relic or Datadog
  • Automated testing before deployment

But honestly? What you've built here can handle most real-world applications. I've used similar setups for production apps serving thousands of users.

Final Thoughts

Remember when deploying meant manually SSH-ing into servers and crossing your fingers? Those days are over.

With this setup, you push code and walk away. Ansible handles the rest. Your app deploys consistently every time, whether it's to 1 server or 100.

Want to see more DevOps content for developers? Let me know in the comments what you'd like to automate next!

Last updated: Monday, August 11, 2025
Subscribe to the Newsletter

Get notified when I publish new articles. No spam, just high-quality tech content. After subscribing, please check your inbox for a confirmation email.

Subscribe to the newsletter