How I hosted a Next.js application on an AWS EC2 Ubuntu server using Bun, PM2, Nginx, DNS, and SSL.
Project: Grabit2Me
Goal: Host a Next.js application on an AWS EC2 Ubuntu server using Bun + PM2 + Nginx + DNS + SSL.
I created an AWS EC2 instance using Ubuntu.
The server gave me a public IP:
<YOUR_EC2_PUBLIC_IP>The private IP was:
<demo_ip>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:
ubuntu@ip-demo-ip:~$The ubuntu user is the Ubuntu user's account on the EC2 server.
I decided to keep the project inside:
/home/ubuntu/Grabit2meI checked my location using:
pwdResult:
/home/ubuntuThen the application directory was:
/home/ubuntu/Grabit2meSo the structure is basically:
/home/ubuntu/
└── Grabit2me/
├── package.json
├── app/
├── public/
└── ...The application belongs inside the project directory.
Server-level software such as:
Nginx
PM2
Node/Bundoesn't need to be installed inside the Grabit2me directory.
The application uses Bun, so Bun was installed on the Ubuntu server.
I verified Bun with:
bun --versionBun is being used for:
dependency installation
application commands
Next.js startupFor example:
bun installinstalls the project's dependencies.
Initially I tried:
sudo npm install -g pm2and got:
sudo: npm: command not foundThat 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:
Terminal
↓
bun run start
↓
Next.jsIf I close the SSH session, the process can stop.
With PM2:
PM2
↓
Next.jsPM2 keeps the application running in the background and can restart it if necessary.
Inside the project:
cd ~/Grabit2meInstall dependencies:
bun installBuild the production application:
bun run buildDevelopment mode and production mode are different.
Production deployment should use the built Next.js application.
The flow is:
Source code
↓
bun run build
↓
Production build
↓
bun run startInstead of directly running:
bun run startI used PM2:
pm2 start "$(which bun)" --name grabit2me -- run startpm2 startTells PM2 to start a process.
"$(which bun)"Finds the actual location of the Bun executable.
For example:
/home/ubuntu/.bun/bin/bunor wherever Bun is installed.
--name grabit2meGives the PM2 process a readable name.
-- run startTells Bun to execute:
bun run startSo conceptually:
PM2
↓
Bun
↓
bun run start
↓
Next.jsI checked:
pm2 statusThe application showed:
grabit2me onlineThat means PM2 successfully started the application.
I used:
pm2 logsThe important output was:
App [grabit2me:0] starting in -fork mode-
App [grabit2me:0] onlineAnd Next.js showed:
▲ Next.js 16.0.10
- Local: http://localhost:3000
- Network: http://<demo_ip>:3000
✓ Starting...
✓ Ready in 590msThis confirmed that the application itself was working.
Next.js was listening on:
3000So directly accessing the application means:
http://<YOUR_EC2_PUBLIC_IP>:3000At this stage:
Internet
↓
EC2 :3000
↓
Next.jsBut I don't want the application to be publicly accessed through :3000 in the final setup.
This is where Nginx comes in.
Nginx is installed on the Ubuntu server, not inside the Next.js project.
I installed it with:
sudo apt update
sudo apt install nginx -yThen checked it:
sudo systemctl status nginxNginx is a web server and reverse proxy.
Without Nginx:
Browser
↓
<YOUR_EC2_PUBLIC_IP>:3000
↓
Next.jsWith Nginx:
Browser
↓
<YOUR_EC2_PUBLIC_IP>:80
↓
Nginx
↓
127.0.0.1:3000
↓
Next.jsThis is called a reverse proxy.
HTTP normally uses port:
80So:
http://<YOUR_EC2_PUBLIC_IP>actually means:
http://<YOUR_EC2_PUBLIC_IP>:80You don't need to type :80.
Next.js uses:
:3000so directly accessing it requires:
http://<YOUR_EC2_PUBLIC_IP>:3000Nginx hides the 3000 port from the public side.
I created:
sudo nano /etc/nginx/sites-available/grabit2meThe configuration is:
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;
}
}listen 80listen 80;Means:
Nginx listens for HTTP requests on port 80.
server_nameserver_name YOUR_DOMAIN;Means:
This Nginx configuration handles requests for this domain.
For example:
server_name grab.example.com;location /location / {Means:
Match requests coming to
/and its paths.
For example:
/
/about
/api
/loginproxy_passThis is the most important line:
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:
this same EC2 machineSo:
Browser
↓
Nginx :80
↓
127.0.0.1:3000
↓
Next.jsNginx doesn't magically discover Next.js.
I explicitly told it where the application is with proxy_pass.
I enabled the configuration:
sudo ln -s /etc/nginx/sites-available/grabit2me /etc/nginx/sites-enabled/Nginx keeps configurations in:
/etc/nginx/sites-available/and enabled configurations in:
/etc/nginx/sites-enabled/I removed the default configuration:
sudo rm /etc/nginx/sites-enabled/defaultThis prevents the default Nginx page from being served instead of my application.
Before reloading Nginx:
sudo nginx -tExpected:
syntax is ok
test is successfulThis is important because I should never blindly reload a broken Nginx configuration.
After the configuration test succeeds:
sudo systemctl reload nginxreload tells Nginx to apply the new configuration without unnecessarily stopping the service.
For Nginx to receive traffic from the Internet, the EC2 Security Group needs:
SSH
TCP
22
My IPand:
HTTP
TCP
80
0.0.0.0/0Eventually HTTPS also needs:
HTTPS
TCP
443
0.0.0.0/0I should not expose port 3000 publicly once Nginx is handling the application.
Final public flow:
Internet
↓
Port 80 / 443
↓
Nginx
↓
localhost:3000I initially tried to use:
rudranboitei.comand discovered that its nameservers are:
ns1.vercel-dns.com
ns2.vercel-dns.comThat means Vercel is the authoritative DNS provider.
So even though Hostinger showed:
A
grab
<YOUR_EC2_PUBLIC_IP>that record in Hostinger isn't necessarily controlling live DNS.
This explained why I was getting:
404 NOT_FOUND
DEPLOYMENT_NOT_FOUNDfrom Vercel.
The request was reaching Vercel instead of my EC2 server.
I already have:
grabit2me.rudranboitei.comas a production application.
Therefore:
Do NOT modify that DNS record.
For testing, I decided to use:
grab.rudranboitei.comas a separate subdomain.
The intended DNS mapping is:
grab.rudranboitei.com
↓
<YOUR_EC2_PUBLIC_IP>
↓
EC2For the new subdomain, the record should be:
Type: A
Name: grab
Value: EC2 IP
TTL: 14400The TTL of 14400 is okay.
An A record means:
Map this hostname to this IPv4 address.
So:
grab.rudranboitei.com
↓
<YOUR_EC2_PUBLIC_IP>Because the domain currently uses:
ns1.vercel-dns.com
ns2.vercel-dns.comthe 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:
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.
Once DNS points grab.rudranboitei.com to the EC2 server, Nginx should use:
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:
sudo nginx -t
sudo systemctl reload nginxI can check DNS using:
dig grab.rudranboitei.comI want it to resolve to:
<YOUR_EC2_PUBLIC_IP>If it still resolves to Vercel, the request will continue going to Vercel.
Before SSL, test:
http://grab.rudranboitei.comNot:
https://grab.rudranboitei.combecause HTTPS has not been configured yet.
The expected flow is:
Browser
↓
http://grab.rudranboitei.com
↓
DNS
↓
<YOUR_EC2_PUBLIC_IP>
↓
EC2
↓
Nginx :80
↓
127.0.0.1:3000
↓
Next.jsSSL should come after HTTP works.
Correct order:
Domain
↓
DNS
↓
HTTP
↓
Nginx
↓
Next.js
↓
SSL
↓
HTTPSThere is no point debugging SSL while DNS or Nginx isn't working.
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:
DNS A Record
↓
Elastic IP
↓
EC2This prevents the domain from breaking because the EC2 public IP changed.
Once the domain works over HTTP:
Install Certbot:
sudo apt install certbot python3-certbot-nginx -yGenerate the certificate:
sudo certbot --nginx -d grab.rudranboitei.comCertbot will configure Nginx for HTTPS.
Then the application becomes:
https://grab.rudranboitei.cominstead of:
http://grab.rudranboitei.comAfter SSL is working:
sudo certbot renew --dry-runIf that succeeds, automatic renewal is working.
Currently PM2 is managing the application.
To make PM2 automatically restore the application after an EC2 reboot:
pm2 saveThen:
pm2 startupPM2 will print a command.
Run that generated command.
Then:
pm2 saveNow the application can automatically come back after a server restart.
The final setup I'm building is:
Internet
│
▼
grab.rudranboitei.com
│
DNS
│
▼
Elastic IP
│
▼
AWS EC2 Ubuntu
│
▼
Nginx :80/:443
│
Reverse Proxy
│
▼
127.0.0.1:3000
│
▼
Next.js
│
▼
Bun
│
▼
PM2| 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 |
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 ✅sudo apt update
sudo apt upgrade -ycd ~/Grabit2me
bun install
bun run buildpm2 start "$(which bun)" --name grabit2me -- run start
pm2 status
pm2 logs
pm2 save
pm2 startupsudo 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 nginxdig grab.rudranboitei.comsudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d grab.rudranboitei.com
sudo certbot renew --dry-runrudranboitei.com uses Vercel nameserversgrabit2me.rudranboitei.comgrab.rudranboitei.com through Vercel DNSdigThe important thing wasn't just memorizing commands.
I now understand the request flow:
Domain
↓
DNS
↓
EC2 Public/Elastic IP
↓
Nginx :80 / :443
↓
proxy_pass
↓
127.0.0.1:3000
↓
Next.js
↓
PM2
↓
BunAnd specifically:
listen 80;means Nginx accepts HTTP traffic on port 80.
server_name grab.rudranboitei.com;means this server block handles that domain.
location / {means match requests coming to the application paths.
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.