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:
- SSH into your server
- Pull the latest code
- Run
npm install
andnpm run build
- Restart your app
- 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:
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
- AMI: Amazon Linux 2 (free tier eligible)
- Instance Type: t2.micro (free tier)
- Security Group: Allow SSH (port 22) from your IP
- 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
- AMI: Amazon Linux 2023 (newer, better performance)
- Instance Type: t3.micro or larger (depending on your app)
- Security Group:
- SSH (port 22) from Ansible Controller
- HTTP (port 3000) from anywhere (or your Load Balancer)
- 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/YOUR-USERNAME/YOUR-REPO.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 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: 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 PM2 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: Ensure 2G swapfile exists (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 and enable swapfile ansible.builtin.shell: | mkswap /swapfile swapon /swapfile - name: Install dependencies 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
:
[nextjs_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)
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:
name: Deploy with Ansible on: push: branches: [main] workflow_dispatch: {} jobs: deploy: runs-on: ubuntu-latest steps: - name: 📥 Checkout repository code uses: actions/checkout@v4 - name: 🔐 Setup SSH key for server access shell: bash run: | set -euo pipefail mkdir -p ~/.ssh && chmod 700 ~/.ssh # Create temporary file for the key TMP_KEY=$(mktemp) printf "%s" "${{ secrets.ANSIBLE_SSH_PRIVATE_KEY }}" > "$TMP_KEY" # Handle different key formats 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..." base64 -d "$TMP_KEY" > ~/.ssh/id_rsa fi chmod 600 ~/.ssh/id_rsa # Validate the key if ! ssh-keygen -y -f ~/.ssh/id_rsa >/dev/null 2>&1; then echo "❌ SSH key is invalid!" exit 1 fi eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_rsa rm -f "$TMP_KEY" - name: 🔒 Add server fingerprints to known hosts shell: bash run: | for host in "${{ secrets.ANSIBLE_HOST }}" "${{ secrets.ANSIBLE_APP_HOST }}"; do if [[ -n "$host" ]]; then ssh-keyscan -H "$host" >> ~/.ssh/known_hosts 2>/dev/null || true fi done - name: 🚀 Deploy Next.js app via Ansible shell: bash run: | set -euo pipefail USER_TO_USE="ec2-user" CONTROL_HOST="${{ secrets.ANSIBLE_HOST }}" SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=30" # Create workspace on 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" # Upload playbook scp ${SSH_OPTS} ansible/deploy.yml "${USER_TO_USE}@${CONTROL_HOST}:/home/${USER_TO_USE}/deploy/" # Create inventory 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 INV scp ${SSH_OPTS} "$INVENTORY" "${USER_TO_USE}@${CONTROL_HOST}:/home/${USER_TO_USE}/deploy/inventory.ini" rm -f "$INVENTORY" # Install Ansible if needed ssh ${SSH_OPTS} "${USER_TO_USE}@${CONTROL_HOST}" ' if ! command -v ansible-playbook >/dev/null 2>&1; then sudo yum -y install python3-pip python3 -m pip install --user ansible echo "export PATH=\"$HOME/.local/bin:$PATH\"" >> ~/.bashrc fi ' # Run deployment ssh ${SSH_OPTS} "${USER_TO_USE}@${CONTROL_HOST}" \ "PATH=\"\$HOME/.local/bin:\$PATH\" ansible-playbook -i /home/${USER_TO_USE}/deploy/inventory.ini /home/${USER_TO_USE}/deploy/deploy.yml" echo "🎉 Deployment completed successfully!"
Add GitHub Secrets
In your GitHub repo, go to Settings → Secrets → Actions and add:
- ANSIBLE_SSH_PRIVATE_KEY: Your .pem file content (the whole file!)
- ANSIBLE_HOST: Your Ansible Controller's public IP
- ANSIBLE_APP_HOST: Your app server's IP
⚠️ Important: Never commit SSH keys to your repository. Always use GitHub Secrets!
Step 6: Deploy and Celebrate! 🎉
Now comes the moment of truth:
- Commit and push your changes to the
main
branch - Watch GitHub Actions run your workflow
- 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. The playbook includes a swapfile creation to prevent this.
"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
:
[nextjs_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.
The best part? You learned this without becoming a DevOps expert. You're still a frontend/fullstack developer — just one who happens to know how to automate deployments like a pro.
Want to see more DevOps content for developers? Follow me and let me know in the comments what you'd like to automate next!
Originally published at TechByCuong.com
Top comments (0)