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:

text
<YOUR_EC2_PUBLIC_IP>

The private IP was:

text
<demo_ip>

What is the difference?

text
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:

bash
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:

text
/home/ubuntu/Grabit2me

I checked my location using:

bash
pwd

Result:

text
/home/ubuntu

Then the application directory was:

text
/home/ubuntu/Grabit2me

So the structure is basically:

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

Important

The application belongs inside the project directory.

Server-level software such as:

text
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:

bash
bun --version

Bun is being used for:

text
dependency installation
application commands
Next.js startup

For example:

bash
bun install

installs the project's dependencies.


4. Install PM2

Initially I tried:

bash
sudo npm install -g pm2

and got:

text
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:

text
Terminal
   ↓
bun run start
   ↓
Next.js

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

With PM2:

text
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:

bash
cd ~/Grabit2me

Install dependencies:

bash
bun install

Build the production application:

bash
bun run build

Why build?

Development mode and production mode are different.

Production deployment should use the built Next.js application.

The flow is:

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

6. Start Next.js with PM2

Instead of directly running:

bash
bun run start

I used PM2:

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

Breaking this command down

bash
pm2 start

Tells PM2 to start a process.

bash
"$(which bun)"

Finds the actual location of the Bun executable.

For example:

text
/home/ubuntu/.bun/bin/bun

or wherever Bun is installed.

text
--name grabit2me

Gives the PM2 process a readable name.

text
-- run start

Tells Bun to execute:

bash
bun run start

So conceptually:

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

7. Check PM2

I checked:

bash
pm2 status

The application showed:

text
grabit2me    online

That means PM2 successfully started the application.


8. Check Application Logs

I used:

bash
pm2 logs

The important output was:

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

And Next.js showed:

text
▲ 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:

text
3000

So directly accessing the application means:

text
http://<YOUR_EC2_PUBLIC_IP>:3000

At this stage:

text
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:

bash
sudo apt update
sudo apt install nginx -y

Then checked it:

bash
sudo systemctl status nginx

Nginx is a web server and reverse proxy.


11. Understand Why Nginx Is Needed

Without Nginx:

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

With Nginx:

text
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:

text
80

So:

text
http://<YOUR_EC2_PUBLIC_IP>

actually means:

text
http://<YOUR_EC2_PUBLIC_IP>:80

You don't need to type :80.

Next.js uses:

text
:3000

so directly accessing it requires:

text
http://<YOUR_EC2_PUBLIC_IP>:3000

Nginx hides the 3000 port from the public side.


13. Create the Nginx Configuration

I created:

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

The configuration is:

nginx
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

nginx
listen 80;

Means:

Nginx listens for HTTP requests on port 80.


server_name

nginx
server_name YOUR_DOMAIN;

Means:

This Nginx configuration handles requests for this domain.

For example:

nginx
server_name grab.example.com;

location /

nginx
location / {

Means:

Match requests coming to / and its paths.

For example:

text
/
 /about
 /api
 /login

proxy_pass

This is the most important line:

nginx
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:

text
this same EC2 machine

So:

text
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:

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

Nginx keeps configurations in:

text
/etc/nginx/sites-available/

and enabled configurations in:

text
/etc/nginx/sites-enabled/

16. Remove the Default Nginx Site

I removed the default configuration:

bash
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:

bash
sudo nginx -t

Expected:

text
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:

bash
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:

text
SSH
TCP
22
My IP

and:

text
HTTP
TCP
80
0.0.0.0/0

Eventually HTTPS also needs:

text
HTTPS
TCP
443
0.0.0.0/0

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

Final public flow:

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

20. DNS — The Important Part

I initially tried to use:

text
rudranboitei.com

and discovered that its nameservers are:

text
ns1.vercel-dns.com
ns2.vercel-dns.com

That means Vercel is the authoritative DNS provider.

So even though Hostinger showed:

text
A
grab
<YOUR_EC2_PUBLIC_IP>

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

This explained why I was getting:

text
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:

text
grabit2me.rudranboitei.com

as a production application.

Therefore:

Do NOT modify that DNS record.

For testing, I decided to use:

text
grab.rudranboitei.com

as a separate subdomain.

The intended DNS mapping is:

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

22. DNS A Record

For the new subdomain, the record should be:

text
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:

text
grab.rudranboitei.com
        ↓
<YOUR_EC2_PUBLIC_IP>

23. Important DNS Problem

Because the domain currently uses:

text
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:

text
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:

nginx
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:

bash
sudo nginx -t
sudo systemctl reload nginx

25. DNS Testing

I can check DNS using:

bash
dig grab.rudranboitei.com

I want it to resolve to:

text
<YOUR_EC2_PUBLIC_IP>

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


26. HTTP Testing

Before SSL, test:

text
http://grab.rudranboitei.com

Not:

text
https://grab.rudranboitei.com

because HTTPS has not been configured yet.

The expected flow is:

text
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:

text
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:

text
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:

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

Generate the certificate:

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

Certbot will configure Nginx for HTTPS.

Then the application becomes:

text
https://grab.rudranboitei.com

instead of:

text
http://grab.rudranboitei.com

30. Test SSL Renewal

After SSL is working:

bash
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:

bash
pm2 save

Then:

bash
pm2 startup

PM2 will print a command.

Run that generated command.

Then:

bash
pm2 save

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


32. Final Architecture

The final setup I'm building is:

text
                         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

TechnologyJob
AWS EC2Provides the server
UbuntuOperating system on the server
SSHLets me remotely access the server
BunRuns/installs the JavaScript application
Next.jsMy web application
PM2Keeps the application running
NginxReverse proxy / web server
DNSConverts domain → server IP
Elastic IPGives EC2 a stable public IP
CertbotGets and configures SSL
Let's EncryptProvides the SSL certificate
HTTPSEncrypts browser ↔ server traffic

🔥 The Complete Deployment Flow

text
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

bash
sudo apt update
sudo apt upgrade -y

Project

bash
cd ~/Grabit2me
bun install
bun run build

PM2

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

Nginx

bash
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

bash
dig grab.rudranboitei.com

SSL

bash
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

  • EC2 Ubuntu server
  • SSH access
  • Project on EC2
  • Bun
  • Dependencies
  • Next.js production build
  • PM2
  • Next.js running on port 3000
  • PM2 logs verified
  • Nginx installed
  • Nginx reverse proxy concept understood
  • Nginx configuration created
  • AWS HTTP port requirement understood
  • DNS concept understood
  • Discovered that rudranboitei.com uses Vercel nameservers
  • 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:

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

And specifically:

nginx
listen 80;

means Nginx accepts HTTP traffic on port 80.

nginx
server_name grab.rudranboitei.com;

means this server block handles that domain.

nginx
location / {

means match requests coming to the application paths.

nginx
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.