Back to Blog

Deploying a React + Vite App to AWS EC2 with Nginx and GitHub Actions CI/CD

Full CI/CD from GitHub push to nginx

14 min read
  • React
  • Vite
  • AWS
  • EC2
  • nginx
  • GitHub Actions
  • CI/CD
Deploying a React + Vite App to AWS EC2 with Nginx and GitHub Actions CI/CD

Deploying a React + Vite App to AWS EC2 with Nginx and GitHub Actions CI/CD

Recently I was exploring AWS and it’s multiple services, EC2 is one of them. I deployed one of my very simple React Application(Vite) to a raw AWS EC2 with nginx and a full CI/CD pipeline using GitHub Actions. I learned a lot from starting to the end of deploying the application. In this blog I will describe step by step how can you deploy your application with necessary config and commands.

Before jump into the process, first question arise on your mind ---

Why EC2 + nginx instead of Vercel/Netlify?

It’s a fair question because services like Vercel/Netlify can take couple of minutes to deploy the application with zero server management. I chose the EC2 route because I wanted to understand what’s happening underneath: how a Linux server serves traffic, how nginx routes requests, how a deploy pipeline is wired by hand.

Architecture Overview

Here’s the shape of the whole system:

 GitHub push → GitHub Actions (CI) → GitHub Actions (Deploy via SSH) →
 EC2 instance → nginx → browser

There are two separate workflows do the work:

  • CI --- runs every push and pull requrest, checks type safety, lint, formatting and that the build succeds.
  • Deploy --- runs only after the CI passes on main, SSHes into the EC2 server, pulls the latest code, rebuilds and copies the new files into nginx’s web root.

Step 1: Launch the EC2 instance

  1. AWS Console → EC2 → Launch Instance
  2. AMI: Ubuntu 24.04 LTS
  3. Instance type: t3.micro (free-tier eligible — plenty for a personal project)
  4. Create a new key pair and download the .pem file — this is your SSH credential, keep it safe
  5. Security group inbound rules:
    • SSH (22) — ideally restricted to your own IP
    • HTTP (80) — open
    • HTTPS (443) — open
  6. Launch, and note the instance’s public IPv4 address

Connect to it:

chmod 400 your-key.pem
ssh -i your-key.pem ubuntu@<public-ip>

Step 2: Install nginx, Node, and git

sudo apt update && sudo apt upgrade -y

sudo apt install nginx git -y
sudo systemctl enable nginx
sudo systemctl start nginx

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install nodejs -y
node -v && npm -v

Step 3: Clone and build the app manually (first time)

cd ~
git clone https://github.com/yourname/yourrepo.git
cd yourrepo
npm install
npm run build

Vite outputs the production bundle to dist/ by default. Copy it into a dedicated web root:

sudo mkdir -p /var/www/yourapp
sudo cp -r dist/* /var/www/yourapp/
sudo chown -R ubuntu:ubuntu /var/www/yourapp

sudo chown -R ubuntu:ubuntu /var/www/yourapp — giving the ubuntu user ownership means later automated deploys can write to this folder without needing sudo, which avoids password-prompt hangs in CI.


Step 4: Configure nginx

Create /etc/nginx/sites-available/yourapp:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com; //If you have your own domain,otherwise we don't need this line

    root /var/www/yourapp;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    gzip on;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml;
}

Two lines are worth understanding, not just copying:

  • try_files $uri $uri/ /index.html; — this handles the client side routing. If you visit /dashboard directly, there’s no file called dashboard on disk; React Router handles that route in the browser. Without this line, nginx returns a 404 instead of falling back to index.html, which is what lets React Router take over and render the right page.
  • The regex location block — tells the browser to cache static assets (JS, CSS, images) for 30 days, since Vite fingerprints filenames on every build (index-abc123.js), so a new build naturally busts the cache without needing to invalidate anything manually.

Enable the config:

sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

If you don’t have a domain yet, use server_name _; and test against the raw IP for now.


If you have a domain:

  1. Allocate an Elastic IP in EC2 and associate it with your instance — this keeps your IP fixed even if the instance restarts.
  2. Add DNS A records for yourdomain.com and www.yourdomain.com pointing to the Elastic IP.
  3. Once DNS has propagated, get a free TLS certificate:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot edits your nginx config automatically to serve HTTPS and redirect HTTP traffic, and sets up auto-renewal.


Step 6: Handle environment variables

Vite inlines any variable prefixed with VITE_ directly into the built JavaScript at build time — which matters because it means the .env file needs to exist wherever npm run build actually runs. In this setup, that’s the EC2 server itself.

The simplest approach: create a .env file directly on the server, in the repo directory:

cd ~/yourrepo
nano .env
VITE_ANY_VARIABLE_URL=https://your-project.varialbe.com
VITE_ANY_PUBLISHABLE_KEY=your-publishable-key

Make sure .env is in .gitignore so it’s never committed. Vite picks it up automatically on every build — no workflow changes needed.


Step 7: Set up CI — quality checks and build verification

Before wiring up automatic deployment, it’s worth having a workflow that catches problems before they ship. Create .github/workflows/ci.yml:

name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    name: Quality (type-check, lint, format)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npm run type-check

      - name: Lint
        run: npm run lint

      - name: Format check
        run: npm run format:check

  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist
          retention-days: 7

What this buys for me:

  • It runs on pull requests too, not just pushes — so problems surface during code review, before anything merges. You can your preferable checks.
  • npm ci (rather than npm install) installs exactly what’s in package-lock.json and fails if it’s out of sync.
  • The concurrency block cancels a previous in-progress run if you push again quickly, saving CI minutes.
  • The build artifact upload isn’t required for deployment — it’s there so you can download and inspect a specific build without needing to deploy it.

Step 8: Set up the deploy workflow

Generate a dedicated deploy key

On the EC2 server:

ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""
cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/deploy_key

Copy the private key output — this goes into GitHub Secrets, never into the repo itself.

Add GitHub repository secrets

Repo → Settings → Secrets and variables → Actions:

Secret name

Value

EC2_HOST

your public IP or domain

EC2_USER

ubuntu

EC2_SSH_KEY

contents of ~/.ssh/deploy_key

The workflow file

Create .github/workflows/deploy.yml. This one is gated on CI success — it doesn’t just trigger on every push, it waits for the CI workflow to finish successfully first:

name: Deploy to EC2

on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
    branches: [main]

jobs:
  deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            cd ~/yourrepo
            git pull origin main
            npm install
            npm run build
            cp -r dist/* /var/www/yourapp/

Breaking down what happens on each deploy:

  1. GitHub spins up a temporary runner (not your EC2 server — a separate throwaway VM).
  2. That runner opens an SSH connection to your EC2 instance using the secret credentials.
  3. On the EC2 box, it pulls the latest commit, reinstalls dependencies, rebuilds the app, and copies the new dist/ output into nginx’s web root — overwriting the previous version.
  4. Nginx doesn’t need a restart. It’s just serving files from disk, so the moment the new build lands in /var/www/yourapp, the next visitor gets the new version.

The workflow_run trigger is the key design decision here: it means a broken build or a failing lint check in CI will simply prevent the deploy workflow from running at all — you can’t accidentally ship code that doesn’t pass your own checks.


Step 9: Push and watch it work

Commit both workflow files, push to main, and watch the Actions tab in GitHub. You should see CI run first, and once it succeeds, Deploy to EC2 kicks off automatically.

From this point forward, the entire deployment loop is: write code → push → get coffee → refresh the live site.

Wrapping up

That’s the full loop: EC2 for compute, nginx for serving and routing, GitHub Actions split into a CI workflow (quality gate) and a deploy workflow (ship on success). If you’re building this yourself, I’d suggest getting each piece working manually first (SSH in, build, copy files, reload nginx) before automating any of it — it makes debugging CI failures far easier when you already know what the commands are supposed to do.