Deploying a React + Vite App to AWS EC2 with Nginx and GitHub Actions CI/CD
Full CI/CD from GitHub push to nginx
- React
- Vite
- AWS
- EC2
- nginx
- 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
- AWS Console → EC2 → Launch Instance
- AMI: Ubuntu 24.04 LTS
- Instance type:
t3.micro(free-tier eligible — plenty for a personal project) - Create a new key pair and download the
.pemfile — this is your SSH credential, keep it safe - Security group inbound rules:
- SSH (22) — ideally restricted to your own IP
- HTTP (80) — open
- HTTPS (443) — open
- 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/dashboarddirectly, there’s no file calleddashboardon disk; React Router handles that route in the browser. Without this line, nginx returns a 404 instead of falling back toindex.html, which is what lets React Router take over and render the right page.- The regex
locationblock — 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.
Step 5: Point a domain and add HTTPS (optional but recommended)
If you have a domain:
- Allocate an Elastic IP in EC2 and associate it with your instance — this keeps your IP fixed even if the instance restarts.
- Add DNS
Arecords foryourdomain.comandwww.yourdomain.compointing to the Elastic IP. - 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 thannpm install) installs exactly what’s inpackage-lock.jsonand fails if it’s out of sync.- The
concurrencyblock 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:
- GitHub spins up a temporary runner (not your EC2 server — a separate throwaway VM).
- That runner opens an SSH connection to your EC2 instance using the secret credentials.
- 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. - 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.