Hosting a Next.js App on AWS EC2 — My Deployment Journey

How I hosted a Next.js application on an AWS EC2 Ubuntu server using Bun, PM2, Nginx, DNS, and SSL.

Published: August 11, 2026

Project: Grabit2Me

Goal: Host a Next.js application on an AWS EC2 Ubuntu server using Bun + PM2 + Nginx + DNS + SSL.


1. Create the EC2 Server

I created an AWS EC2 instance using Ubuntu.

The server gave me a public IP:

Code
<YOUR_EC2_PUBLIC_IP>

The private IP was:

Code
<demo_ip>

What is the difference?

Code
Public IP
→ Used to access the EC2 server from the Internet.
 
Private IP
→ Used internally inside the AWS network.

I connected to the server through SSH and got:

Code
ubuntu@ip-demo-ip:~$

The ubuntu user is the Ubuntu user's account on the EC2 server.


2. Understand Where the Application Lives

I decided to keep the project inside:

Code
/home/ubuntu/Grabit2me

I checked my location using:

Code
pwd

Result:

Code
/home/ubuntu

Then the application directory was:

Code
/home/ubuntu/Grabit2me

So the structure is basically:

Code
/home/ubuntu/
└── Grabit2me/
    ├── package.json
    ├── app/
    ├── public/
    └── ...

Important

The application belongs inside the project directory.

Server-level software such as:

Code
Nginx
PM2
Node/Bun

doesn't need to be installed inside the Grabit2me directory.


3. Install Bun

The application uses Bun, so Bun was installed on the Ubuntu server.

I verified Bun with:

Code
bun --version

Bun is being used for:

Code
dependency installation
application commands
Next.js startup

For example:

Code
bun install

installs the project's dependencies.


4. Install PM2

Initially I tried:

Code
sudo npm install -g pm2

and got:

Code
sudo: npm: command not found

That happened because Node/npm wasn't available in the environment yet.

After getting Node/npm available, PM2 was installed.

PM2 is a process manager.

Without PM2:

Code
Terminal
   ↓
bun run start
   ↓
Next.js

If I close the SSH session, the process can stop.

With PM2:

Code
PM2
 ↓
Next.js

PM2 keeps the application running in the background and can restart it if necessary.


5. Build the Next.js Application

Inside the project:

Code
cd ~/Grabit2me

Install dependencies:

Code
bun install

Build the production application:

Code
bun run build

Why build?

Development mode and production mode are different.

Production deployment should use the built Next.js application.

The flow is:

Code
Source code
    ↓
bun run build
    ↓
Production build
    ↓
bun run start

6. Start Next.js with PM2

Instead of directly running:

Code
bun run start

I used PM2:

Code
pm2 start "$(which bun)" --name grabit2me -- run start

Breaking this command down

Code
pm2 start

Tells PM2 to start a process.

Code
"$(which bun)"

Finds the actual location of the Bun executable.

For example:

Code
/home/ubuntu/.bun/bin/bun

or wherever Bun is installed.

Code
--name grabit2me

Gives the PM2 process a readable name.

Code
-- run start

Tells Bun to execute:

Code
bun run start

So conceptually:

Code
PM2
 ↓
Bun
 ↓
bun run start
 ↓
Next.js

7. Check PM2

I checked:

Code
pm2 status

The application showed:

Code
grabit2me    online

That means PM2 successfully started the application.


8. Check Application Logs

I used:

Code
pm2 logs

The important output was:

Code
App [grabit2me:0] starting in -fork mode-
App [grabit2me:0] online

And Next.js showed:

Code
▲ Next.js 16.0.10
 
- Local:    http://localhost:3000
- Network:  http://<demo_ip>:3000
 
✓ Starting...
✓ Ready in 590ms

This confirmed that the application itself was working.


9. Understand Port 3000

Next.js was listening on:

Code
3000

So directly accessing the application means:

Code
http://<YOUR_EC2_PUBLIC_IP>:3000

At this stage:

Code
Internet
   ↓
EC2 :3000
   ↓
Next.js

But I don't want the application to be publicly accessed through :3000 in the final setup.

This is where Nginx comes in.


10. Install Nginx

Nginx is installed on the Ubuntu server, not inside the Next.js project.

I installed it with:

Code
sudo apt update
sudo apt install nginx -y

Then checked it:

Code
sudo systemctl status nginx

Nginx is a web server and reverse proxy.


11. Understand Why Nginx Is Needed

Without Nginx:

Code
Browser
   ↓
<YOUR_EC2_PUBLIC_IP>:3000
   ↓
Next.js

With Nginx:

Code
Browser
   ↓
<YOUR_EC2_PUBLIC_IP>:80
   ↓
Nginx
   ↓
127.0.0.1:3000
   ↓
Next.js

This is called a reverse proxy.


12. Why Port 80 Instead of 3000?

HTTP normally uses port:

Code
80

So:

Code
http://<YOUR_EC2_PUBLIC_IP>

actually means:

Code
http://<YOUR_EC2_PUBLIC_IP>:80

You don't need to type :80.

Next.js uses:

Code
:3000

so directly accessing it requires:

Code
http://<YOUR_EC2_PUBLIC_IP>:3000

Nginx hides the 3000 port from the public side.


13. Create the Nginx Configuration

I created:

Code
sudo nano /etc/nginx/sites-available/grabit2me

The configuration is:

Code
server {
    listen 80;
    server_name YOUR_DOMAIN;
 
    location / {
        proxy_pass http://127.0.0.1:3000;
 
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

14. Understand the Nginx Configuration

listen 80

Code
listen 80;

Means:

Nginx listens for HTTP requests on port 80.


server_name

Code
server_name YOUR_DOMAIN;

Means:

This Nginx configuration handles requests for this domain.

For example:

Code
server_name grab.example.com;

location /

Code
location / {

Means:

Match requests coming to / and its paths.

For example:

Code
/
 /about
 /api
 /login

proxy_pass

This is the most important line:

Code
proxy_pass http://127.0.0.1:3000;

It tells Nginx:

Forward the request to the Next.js application running on port 3000.

127.0.0.1 means:

Code
this same EC2 machine

So:

Code
Browser
   ↓
Nginx :80
   ↓
127.0.0.1:3000
   ↓
Next.js

Nginx doesn't magically discover Next.js.

I explicitly told it where the application is with proxy_pass.


15. Enable the Nginx Site

I enabled the configuration:

Code
sudo ln -s /etc/nginx/sites-available/grabit2me /etc/nginx/sites-enabled/

Nginx keeps configurations in:

Code
/etc/nginx/sites-available/

and enabled configurations in:

Code
/etc/nginx/sites-enabled/

16. Remove the Default Nginx Site

I removed the default configuration:

Code
sudo rm /etc/nginx/sites-enabled/default

This prevents the default Nginx page from being served instead of my application.


17. Test Nginx Configuration

Before reloading Nginx:

Code
sudo nginx -t

Expected:

Code
syntax is ok
test is successful

This is important because I should never blindly reload a broken Nginx configuration.


18. Reload Nginx

After the configuration test succeeds:

Code
sudo systemctl reload nginx

reload tells Nginx to apply the new configuration without unnecessarily stopping the service.


19. AWS Security Group

For Nginx to receive traffic from the Internet, the EC2 Security Group needs:

Code
SSH
TCP
22
My IP

and:

Code
HTTP
TCP
80
0.0.0.0/0

Eventually HTTPS also needs:

Code
HTTPS
TCP
443
0.0.0.0/0

I should not expose port 3000 publicly once Nginx is handling the application.

Final public flow:

Code
Internet
   ↓
Port 80 / 443
   ↓
Nginx
   ↓
localhost:3000

20. DNS — The Important Part

I initially tried to use:

Code
rudranboitei.com

and discovered that its nameservers are:

Code
ns1.vercel-dns.com
ns2.vercel-dns.com

That means Vercel is the authoritative DNS provider.

So even though Hostinger showed:

Code
A
grab
<YOUR_EC2_PUBLIC_IP>

that record in Hostinger isn't necessarily controlling live DNS.

This explained why I was getting:

Code
404 NOT_FOUND
DEPLOYMENT_NOT_FOUND

from Vercel.

The request was reaching Vercel instead of my EC2 server.


21. Existing Production Domain

I already have:

Code
grabit2me.rudranboitei.com

as a production application.

Therefore:

Do NOT modify that DNS record.

For testing, I decided to use:

Code
grab.rudranboitei.com

as a separate subdomain.

The intended DNS mapping is:

Code
grab.rudranboitei.com
        ↓
<YOUR_EC2_PUBLIC_IP>
        ↓
EC2

22. DNS A Record

For the new subdomain, the record should be:

Code
Type:   A
Name:   grab
Value:  EC2 IP
TTL:    14400

The TTL of 14400 is okay.

An A record means:

Map this hostname to this IPv4 address.

So:

Code
grab.rudranboitei.com
        ↓
<YOUR_EC2_PUBLIC_IP>

23. Important DNS Problem

Because the domain currently uses:

Code
ns1.vercel-dns.com
ns2.vercel-dns.com

the live DNS needs to be managed through Vercel.

Therefore, the Hostinger DNS record alone won't solve the problem.

The safe approach for the existing domain is:

Code
Vercel DNS
   ↓
A record
   ↓
grab
   ↓
<YOUR_EC2_PUBLIC_IP>

I should not change the domain's nameservers just for this experiment because that could affect existing production services.


24. Domain → Nginx

Once DNS points grab.rudranboitei.com to the EC2 server, Nginx should use:

Code
server {
    listen 80;
    server_name grab.rudranboitei.com;
 
    location / {
        proxy_pass http://127.0.0.1:3000;
 
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then:

Code
sudo nginx -t
sudo systemctl reload nginx

25. DNS Testing

I can check DNS using:

Code
dig grab.rudranboitei.com

I want it to resolve to:

Code
<YOUR_EC2_PUBLIC_IP>

If it still resolves to Vercel, the request will continue going to Vercel.


26. HTTP Testing

Before SSL, test:

Code
http://grab.rudranboitei.com

Not:

Code
https://grab.rudranboitei.com

because HTTPS has not been configured yet.

The expected flow is:

Code
Browser
   ↓
http://grab.rudranboitei.com
   ↓
DNS
   ↓
<YOUR_EC2_PUBLIC_IP>
   ↓
EC2
   ↓
Nginx :80
   ↓
127.0.0.1:3000
   ↓
Next.js

27. Why We Haven't Done SSL Yet

SSL should come after HTTP works.

Correct order:

Code
Domain
   ↓
DNS
   ↓
HTTP
   ↓
Nginx
   ↓
Next.js
   ↓
SSL
   ↓
HTTPS

There is no point debugging SSL while DNS or Nginx isn't working.


28. Elastic IP

One important production step remains before treating the EC2 IP as permanent.

EC2's normal public IP can change after stopping/starting the instance.

So allocate an Elastic IP in AWS and attach it to the EC2 instance.

Then:

Code
DNS A Record
     ↓
Elastic IP
     ↓
EC2

This prevents the domain from breaking because the EC2 public IP changed.


29. SSL / HTTPS — Next Step

Once the domain works over HTTP:

Install Certbot:

Code
sudo apt install certbot python3-certbot-nginx -y

Generate the certificate:

Code
sudo certbot --nginx -d grab.rudranboitei.com

Certbot will configure Nginx for HTTPS.

Then the application becomes:

Code
https://grab.rudranboitei.com

instead of:

Code
http://grab.rudranboitei.com

30. Test SSL Renewal

After SSL is working:

Code
sudo certbot renew --dry-run

If that succeeds, automatic renewal is working.


31. Make PM2 Survive Reboots

Currently PM2 is managing the application.

To make PM2 automatically restore the application after an EC2 reboot:

Code
pm2 save

Then:

Code
pm2 startup

PM2 will print a command.

Run that generated command.

Then:

Code
pm2 save

Now the application can automatically come back after a server restart.


32. Final Architecture

The final setup I'm building is:

Code
                         Internet
                            │
                            ▼
                grab.rudranboitei.com
                            │
                           DNS
                            │
                            ▼
                       Elastic IP
                            │
                            ▼
                     AWS EC2 Ubuntu
                            │
                            ▼
                     Nginx :80/:443
                            │
                     Reverse Proxy
                            │
                            ▼
                    127.0.0.1:3000
                            │
                            ▼
                         Next.js
                            │
                            ▼
                           Bun
                            │
                            ▼
                          PM2

🧠 What Each Tool Is Doing

| Technology | Job | | ----------------- | ---------------------------------------- | | AWS EC2 | Provides the server | | Ubuntu | Operating system on the server | | SSH | Lets me remotely access the server | | Bun | Runs/installs the JavaScript application | | Next.js | My web application | | PM2 | Keeps the application running | | Nginx | Reverse proxy / web server | | DNS | Converts domain → server IP | | Elastic IP | Gives EC2 a stable public IP | | Certbot | Gets and configures SSL | | Let's Encrypt | Provides the SSL certificate | | HTTPS | Encrypts browser ↔ server traffic |


🔥 The Complete Deployment Flow

Code
1. Create EC2
       ↓
2. SSH into Ubuntu
       ↓
3. Install required runtime
       ↓
4. Get Next.js project
       ↓
5. bun install
       ↓
6. bun run build
       ↓
7. Start with PM2
       ↓
8. Verify PM2 + logs
       ↓
9. Next.js running on :3000
       ↓
10. Install Nginx
       ↓
11. Configure reverse proxy
       ↓
12. Enable Nginx site
       ↓
13. nginx -t
       ↓
14. Reload Nginx
       ↓
15. Configure AWS Security Group
       ↓
16. Get stable Elastic IP
       ↓
17. Create DNS A record
       ↓
18. Domain → Elastic IP
       ↓
19. Test HTTP
       ↓
20. Install Certbot
       ↓
21. Configure SSL
       ↓
22. Test HTTPS
       ↓
23. Test SSL renewal
       ↓
24. Configure PM2 startup
       ↓
25. Deployment complete ✅

📌 Commands I Need to Remember

Server

Code
sudo apt update
sudo apt upgrade -y

Project

Code
cd ~/Grabit2me
bun install
bun run build

PM2

Code
pm2 start "$(which bun)" --name grabit2me -- run start
pm2 status
pm2 logs
pm2 save
pm2 startup

Nginx

Code
sudo apt install nginx -y
 
sudo nano /etc/nginx/sites-available/grabit2me
 
sudo ln -s /etc/nginx/sites-available/grabit2me /etc/nginx/sites-enabled/
 
sudo rm /etc/nginx/sites-enabled/default
 
sudo nginx -t
 
sudo systemctl reload nginx
 
sudo systemctl status nginx

DNS testing

Code
dig grab.rudranboitei.com

SSL

Code
sudo apt install certbot python3-certbot-nginx -y
 
sudo certbot --nginx -d grab.rudranboitei.com
 
sudo certbot renew --dry-run

🎯 Current Status — 11 August 2026

Completed

  • [x] EC2 Ubuntu server
  • [x] SSH access
  • [x] Project on EC2
  • [x] Bun
  • [x] Dependencies
  • [x] Next.js production build
  • [x] PM2
  • [x] Next.js running on port 3000
  • [x] PM2 logs verified
  • [x] Nginx installed
  • [x] Nginx reverse proxy concept understood
  • [x] Nginx configuration created
  • [x] AWS HTTP port requirement understood
  • [x] DNS concept understood
  • [x] Discovered that rudranboitei.com uses Vercel nameservers
  • [x] Avoided touching production grabit2me.rudranboitei.com

Remaining

  • [ ] Buy/use a separate domain for clean testing OR safely manage grab.rudranboitei.com through Vercel DNS
  • [ ] Point DNS to EC2
  • [ ] Verify dig
  • [ ] Test HTTP
  • [ ] Allocate Elastic IP
  • [ ] Update DNS to Elastic IP
  • [ ] Install Certbot
  • [ ] Configure HTTPS
  • [ ] Test SSL renewal
  • [ ] Configure PM2 startup
  • [ ] Final reboot/deployment test

💡 What I Actually Learned

The important thing wasn't just memorizing commands.

I now understand the request flow:

Code
Domain
  ↓
DNS
  ↓
EC2 Public/Elastic IP
  ↓
Nginx :80 / :443
  ↓
proxy_pass
  ↓
127.0.0.1:3000
  ↓
Next.js
  ↓
PM2
  ↓
Bun

And specifically:

Code
listen 80;

means Nginx accepts HTTP traffic on port 80.

Code
server_name grab.rudranboitei.com;

means this server block handles that domain.

Code
location / {

means match requests coming to the application paths.

Code
proxy_pass http://127.0.0.1:3000;

means forward those requests to my Next.js application running locally on port 3000.

That is the core of the deployment setup.