I am a third-year computer science student at IIIT Sonepat. Recently, I deployed my chat application, FastChat, on a live AWS EC2 server with HTTPS support, a domain name, and a proper Nginx reverse proxy. This blog is going to be a description of exactly what I did, how it all fits together, and what I did wrong so you don’t have to.
What I Built
FastChat is a REST + WebSocket chat API built with:
App: Node.js, Express.js, Socket.io, MongoDB, PostgreSQL, Redis, AWS S3 (avatar storage), Jest + Supertest (testing)
Infrastructure: Docker, Docker Compose, Nginx, AWS EC2, Let's Encrypt (SSL), DuckDNS (free domain)
The live API is running at https://fastchat.duckdns.org
Note: This URL may not always be live as I shut down the EC2 instance when not in use to avoid AWS charges. If you want to see the code instead, check out the GitHub repo at codephoenix86.
Architecture Overview
Before jumping into steps, here's how everything connects:
The key security decision: only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are exposed to the internet. The databases and the app itself on port 3000 are completely internal — no one can reach them directly. All traffic must go through Nginx.
I used two Docker networks to enforce this:
- fastchat network — connects FastChat app to its databases
- shared gateway network — connects FastChat app to Nginx only
Step-by-Step: How I Deployed It
Step 1 — Build and Push Docker Image
First, I built the Docker image of my chat app locally and pushed it to DockerHub:
docker build -t nareshlohar86/fastchat .
docker push nareshlohar86/fastchat
Now the image is available anywhere, including my EC2 server.
Step 2 — Write the Docker Compose Files
I split my setup into two repositories:
- fastchat — the app itself (Node.js + 3 databases)
- infra — Nginx reverse proxy + Certbot for SSL
fastchat/compose.yml runs 4 services — fastchat, mongodb, postgres, redis — all connected via fastchat network. FastChat is also connected to shared-gateway so Nginx can reach it. Databases have no external port exposure.
infra/compose.yml runs 2 services — nginx and certbot — both connected to shared-gateway. Nginx is the only container with ports 80 and 443 exposed to the host.
Step 3 — Launch an EC2 Instance
I created a free AWS account, launched a t3.micro Ubuntu instance, and downloaded the .pem key pair for SSH access.
In the Security Group, I opened exactly 3 ports:
- Port 22 — SSH
- Port 80 — HTTP
- Port 443 — HTTPS
If you're new to AWS, here's the official guide: https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-creating.html
Step 4 — Set Up the Server
SSH into the instance:
ssh -i ~/.ssh/my-aws-key.pem ubuntu@<your-ec2-public-ip>
Then install the tools needed:
# Install Docker (simplest method)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
# Install Git
sudo apt install git -y
# Install NVM + Node.js (LTS)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
source ~/.bashrc
nvm install --lts
Then clone both repositories:
git clone git@github.com:yourusername/fastchat.git
git clone git@github.com:yourusername/infra.git
To authenticate with GitHub via SSH, generate a key pair on EC2 (
ssh-keygen) and add the public key to your GitHub account settings.
Step 5 — Set Up a Free Domain with DuckDNS
To get HTTPS, you need a domain name. I used DuckDNS — it's completely free.
- Go to https://www.duckdns.org and log in
- Claim a subdomain (e.g.,
fastchat.duckdns.org) - Point it to your EC2 public IP address
Done. Your app now has a real domain.
Step 6 — Configure Nginx with Bind Mounts
Since Nginx runs inside a Docker container, its filesystem is isolated. To let Nginx read my config files and SSL certificates, I used bind mounts — they link a folder on the host machine to a path inside the container.
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # config (read-only)
- ./certbot/certs:/etc/letsencrypt # SSL certificates
- ./certbot/www:/var/www/certbot # for domain verification
My initial Nginx config listens on port 80 and serves the Let's Encrypt verification path:
server {
listen 80;
server_name fastchat.duckdns.org;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri; # redirect everything else to HTTPS
}
}
Both Nginx and Certbot share the same volumes:
certbot:
image: certbot/certbot
volumes:
- ./certbot/certs:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
This is the key to how domain verification works without downtime:
-
./certbot/www:/var/www/certbot— Certbot writes the secret verification token here. Nginx serves it when Let's Encrypt sends a request to/.well-known/acme-challenge/. Both containers read/write the same folder on the host machine. -
./certbot/certs:/etc/letsencrypt— Once the certificate is issued, Certbot stores it here. Nginx reads the certificate from this same folder to enable HTTPS.
This shared volume approach means Nginx and Certbot never need to talk to each other directly — they just read and write to the same folders on the host machine.
Step 7 — Get a Free SSL Certificate with Certbot
Important: Start Nginx first, then run Certbot.
(I learned this the hard way — see Challenges below.)
docker compose up -d nginx # inside infra/ folder
Then issue the certificate:
docker compose run --rm certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
--email your-email@example.com \
--agree-tos \
--no-eff-email \
-d fastchat.duckdns.org
How this works: Let's Encrypt sends an HTTP request to your domain asking for a secret token. Certbot writes that token to /var/www/certbot, and since Nginx is already serving that path, Let's Encrypt can read it and verify you own the domain. No downtime, no stopping your server.
After this succeeds, Certbot stores your certificate at /etc/letsencrypt/live/fastchat.duckdns.org/.
Step 8 — Enable HTTPS in Nginx
Update the Nginx config to add a second server block for port 443:
server {
listen 443 ssl;
server_name fastchat.duckdns.org;
ssl_certificate /etc/letsencrypt/live/fastchat.duckdns.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/fastchat.duckdns.org/privkey.pem;
location / {
proxy_pass http://fastchat:3000;
}
}
Nginx handles the TLS handshake and decryption, then forwards plain HTTP to the FastChat container on the internal Docker network. The app never touches SSL — it just handles business logic.
Step 9 — Start Everything
# Start app and databases
cd ~/fastchat
docker compose up -d
# Run PostgreSQL migrations
docker exec -e DATABASE_URL=<your-database-url> fastchat npm run migrate:up
# Start Nginx
cd ~/infra
docker compose up -d nginx
The app is now live. You can verify with the health check endpoint:
GET https://fastchat.duckdns.org/health
Response:
{
"status": "OK",
"version": "1.0.0",
"checks": {
"mongodb": "connected",
"postgresql": "connected",
"redis": "connected"
}
}
Challenges I Faced (The Real Learning)
1. Certbot failing because Nginx wasn't running
I kept running the Certbot command first, forgetting that Let's Encrypt needs an active HTTP server to verify the domain. Fixed by always starting Nginx before requesting a certificate.
2. App crashed on startup — forgot to update compose.yml after switching to S3
I had originally built the app with local file storage. When I switched to S3, I updated the code and added the S3 variables to my .env file locally — but forgot to add them to the environment section in compose.yml. So when the container started on EC2, the app crashed immediately because the S3 variables didn't exist inside the container.
The fix was simply adding the missing variables to compose.yml:
services:
fastchat:
image: nareshlohar86/fastchat
environment:
- AWS_REGION=your_aws_region
- AWS_ACCESS_KEY_ID=your_aws_access_key_id
- AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
- S3_BUCKET_NAME=your_s3_bucket_name
Lesson: whenever you add new environment variables to your app, update compose.yml at the same time — don't leave it for later.
3. Nginx crashed because FastChat wasn't running yet
I started the infra compose before fastchat. Nginx read its config, tried to resolve the fastchat hostname via Docker DNS, failed because that container didn't exist yet, and crashed. Always start the app first, then the reverse proxy.
4. A typo that took way too long to debug
I wrote ssl_certificate twice instead of ssl_certificate + ssl_certificate_key. Nginx threw a cryptic error and I stared at it for too long. Read your config carefully, character by character.
5. Forgot to run PostgreSQL migrations
Every time I redeployed with a fresh database, I forgot to run migrations. The /auth/signup endpoint would fail with a table-not-found error. I've since added a reminder comment at the top of my deployment notes.
6. Certbot failing due to HTTP → HTTPS redirect
When I first ran the Certbot command, it kept failing during domain verification. The reason: I had Nginx configured to redirect all HTTP traffic to HTTPS. But Let's Encrypt verifies your domain by sending a plain HTTP request to /.well-known/acme-challenge/. That request was getting redirected to HTTPS — which didn't exist yet because I didn't have the certificate yet. Classic chicken-and-egg problem.
The fix was to temporarily remove the HTTPS redirect from Nginx config, leaving only the acme-challenge block on port 80:
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# location / {
# return 301 https://$host$request_uri; ← comment this out temporarily
# }
Get the certificate first, then uncomment the redirect and reload Nginx.
7. Accidentally starting Certbot container along with Nginx
My infra/compose.yml has two services — nginx and certbot. Early on I kept running docker compose up -d inside the infra folder which starts both services together. But certbot is a one-time job — it just issues the certificate and exits. Running it repeatedly caused unnecessary errors and confusion.
The fix was to start them separately:
# start only nginx (run this every time)
docker compose up -d nginx
# run certbot only once to issue certificate
docker compose run --rm certbot certonly ...
Lesson: use docker compose up -d <service-name> to start a specific service instead of all services at once. Certbot is not a long-running service — it does its job and exits.
What I Learned
Before this, I thought deploying meant just running node index.js on a server somewhere. I didn't appreciate how much infrastructure sits between your code and the internet.
The biggest mental shift: your app should not care about SSL, ports, or the outside world. Nginx handles all of that. Your app just receives clean HTTP requests on an internal port. This separation makes everything simpler and more secure.
The Docker networking model also clicked for me here — different networks for different trust boundaries. Databases don't need to know the internet exists.
What's Next
- Set up CI/CD with GitHub Actions (auto-deploy on push to main)
- Add Prometheus metrics + Grafana dashboard
- Refactor into microservices (Auth service, Chat service, User service)
- Deploy to Kubernetes (EKS)
I'm a CS student documenting my journey from student projects to production-grade systems. If you're building something similar or have suggestions, connect with me on LinkedIn or check out my code on GitHub.









Top comments (5)
great! naresh.. i think this post will help me in deploying my project
Good work naresh
Amazing insights
Now you’re officially a cloud engineer😎
😅