Automating an EC2 instance-hosted site deployment with GitHub Actions

Rian Schmidt

April 03, 2024

Table of Contents:

Automating an EC2-hosted site deployment with GitHub Actions, PM2, Node, and a little Nginx magic.
Setting the Stage
GitHub Actions: The Automation Wizard
Brewing the Node.js Potion _(More ChatGPT wit.)_
Directing Traffic with `jwilder/nginx-proxy`
Enlisting PM2: The Process Guardian
Conclusion: Magic Unleashed

Automating an EC2-hosted site deployment with GitHub Actions, PM2, Node, and a little Nginx magic.

OK, lemme try this again (with a little help from ChatGPT so I don't have to type it all again). Remember the backstory is that I'm self-hosting my Remix site because Netlify has issues hosting Remix/Vite sites at the moment. The problem with that is that Netlify did this nice CI stuff that deployed whenever I pushed. So, I needed to cook that up myself.

Here comes the ChatGPT part:

Ever felt like your deployment process could use a bit of magic? πŸ§™β€β™‚οΈβœ¨ Today, we'll dive into setting up an automated pipeline that makes deploying as easy as pushing to GitHub. Whether you're a solo developer or part of a team, automating your deployment process can save time, reduce errors, and make your life a whole lot easier.

(Can you tell?)

Setting the Stage

Before we jump into the code, let's set the stage. We're going to use:

  • GitHub Actions: For automating our deployment workflow.
  • Node.js: Our server-side hero for handling web requests.
  • jwilder/nginx-proxy: An automated Nginx reverse proxy to manage our traffic.
  • PM2: To keep our Node.js app alive forever and ever.

Sounds exciting? Let's roll!

GitHub Actions: The Automation Wizard

GitHub Actions makes it easy to automate your deployment workflows. Here, we'll create a workflow that triggers on every push to the main branch.

name: Deploy to EC2 on Push
on:
  push:
    branches:
      - main
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger EC2 Deployment
        uses: fjogeleit/http-request-action@master
        with:
          url: 'https://yourdomain.com/deploy'
          method: 'POST'
          contentType: 'application/json'
          data: '{"secret":"${{ secrets.DEPLOY_SECRET }}"}'

Replace https://yourdomain.com/deploy with your actual deployment URL. This workflow sends a POST request to trigger our deployment.

The secrets bit is stored in GitHub Secrets to make this a little more secure.

Brewing the Node.js Potion (More ChatGPT wit.)

Our Node.js app needs to listen for the POST request to kick off the deployment process. Here's a simple Express setup in a file called deploy.mjs:

import express from 'express';
import { exec }  from 'child_process';
const app = express();
const port = 6666; 
const SECRET_TOKEN = process.env.DEPLOY_SECRET;

app.use(express.json());
app.get('/deploy', (req, res) => {
    res.send('Pong') // testing
});

app.post('/deploy', (req, res) => {
  if (req.body.secret !== SECRET_TOKEN) {
    return res.status(401).send('Unauthorized');
  }
  // Pull the latest code, then trigger yarn build and Docker restart
  res.status(200).json({status: "success", message: "Webhook received."});
  exec('cd /var/www/pathypathpath && git pull origin main && docker-compose build sitename  && docker-compose down && docker-compose up -d', (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return res.status(500).send('Deployment failed');
    }
    console.log(`stdout: ${stdout}`);
    console.error(`stderr: ${stderr}`);
  });
});

app.listen(port, () => {
  console.log(`Webhook listener running on port ${port}`);
});

Directing Traffic with jwilder/nginx-proxy

I use jwilder/nginx-proxy to act as a reverse proxy on my host to enable me to host lots of name-based virtual servers.

Let's configure it to handle our special /deploy endpoint.

Inside the vhost.d directory, create a file named after your domain (e.g., yourdomain.com) and add:

location /deploy {
    proxy_pass http://172.17.0.1:6666; # Your Node app's internal address
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

** Let me interrupt the robot again here to add some context. What this does is to modify the automatically generated nginx config for the site to add a different "upstream" for the /deploy path. That 172.17.0.1 address is the address of the Docker host (i.e., the EC2 instance, itself). We do this because you need something running outside of the containers to manage the pulling and restarting of things.

Enlisting PM2: The Process Guardian

PM2 keeps our app running. Here's how we define our ecosystem.config.js for PM2:

module.exports = {
  apps: [{
    name: 'deploy-app',
    script: './deploy.mjs',
    interpreter: 'node',
    watch: true,
    env: {
      "NODE_ENV": "development",
    },
  }]
};

Run pm2 start ecosystem.config.js to breathe life into your app. <sigh>

Conclusion: Magic Unleashed

And there you have it, folks! A magical pipeline that deploys your app with every git push. No more manual steps, just pure, automated bliss. πŸŽ‰ (Editor's note: πŸ™„)

As you've seen, combining GitHub Actions with Node.js, jwilder/nginx-proxy, and PM2 can create a powerful deployment workflow that saves time and reduces errors. So why wait? Automate away and make your deployments a breeze.

Until next time, happy coding! πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

OK, well, I apologize for my Terminator friend. He's kind of a dork. Anyway, most of that is right. Happy to clarify if you have questions below.

Circinaut is a Fractional CTO services provider, based in Portland, Oregon, working with clients all across the country. I focus on application development, technology advising, and ongoing support for small and medium-sized businesses.
If your business is in need of a part-time CTO, a fractional CTO, or a contract technical consultant, drop me a line. I'm happy to have a quick chat to discuss your situation with no sales pressure at all (really!).