What I Built
Architecture Overview
Step-by-Step: How I Deployed It
Step 1 — Build and Push Docker Image
Step 2 — Write the Docker Compose Files
Step 3 — Launch an EC2 Instance
Step 4 — Set Up the Server
Step 5 — Set Up a Free Domain with DuckDNS
Step 6 — Configure Nginx with Bind Mounts
Step 7 — Get a Free SSL Certificate with Certbot
Step 8 — Enable HTTPS in Nginx
Step 9 — Start Everything
Challenges I Faced (The Real Learning)
What I Learned
What's Next 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. 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. 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: First, I built the Docker image of my chat app locally and pushed it to DockerHub: Now the image is available anywhere, including my EC2 server. I split my setup into two repositories: 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. 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: If you're new to AWS, here's the official guide: https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-creating.html SSH into the instance: Then install the tools needed: Then clone both repositories: To authenticate with GitHub via SSH, generate a key pair on EC2 (ssh-keygen) and add the public key to your GitHub account settings. To get HTTPS, you need a domain name. I used DuckDNS — it's completely free. Done. Your app now has a real domain. 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. My initial Nginx config listens on port 80 and serves the Let's Encrypt verification path: Both Nginx and Certbot share the same volumes: This is the key to how domain verification works without downtime: 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. Important: Start Nginx first, then run Certbot. (I learned this the hard way — see Challenges below.) Then issue the certificate: 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/. Update the Nginx config to add a second server block for port 443: 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. The app is now live. You can verify with the health check endpoint: 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: 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 yetI 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 debugI 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 migrationsEvery 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 redirectWhen 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: 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: 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. 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
$ -weight: 500;">docker build -t nareshlohar86/fastchat .
-weight: 500;">docker push nareshlohar86/fastchat
-weight: 500;">docker build -t nareshlohar86/fastchat .
-weight: 500;">docker push nareshlohar86/fastchat
-weight: 500;">docker build -t nareshlohar86/fastchat .
-weight: 500;">docker push nareshlohar86/fastchat
ssh -i ~/.ssh/my-aws-key.pem ubuntu@<your-ec2-public-ip>
ssh -i ~/.ssh/my-aws-key.pem ubuntu@<your-ec2-public-ip>
ssh -i ~/.ssh/my-aws-key.pem ubuntu@<your-ec2-public-ip>
# Install Docker (simplest method)
-weight: 500;">curl -fsSL https://get.-weight: 500;">docker.com | sh
-weight: 600;">sudo usermod -aG -weight: 500;">docker $USER
newgrp -weight: 500;">docker # Install Git
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -weight: 500;">git -y # Install NVM + Node.js (LTS)
-weight: 500;">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/-weight: 500;">install.sh | bash
source ~/.bashrc
nvm -weight: 500;">install --lts
# Install Docker (simplest method)
-weight: 500;">curl -fsSL https://get.-weight: 500;">docker.com | sh
-weight: 600;">sudo usermod -aG -weight: 500;">docker $USER
newgrp -weight: 500;">docker # Install Git
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -weight: 500;">git -y # Install NVM + Node.js (LTS)
-weight: 500;">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/-weight: 500;">install.sh | bash
source ~/.bashrc
nvm -weight: 500;">install --lts
# Install Docker (simplest method)
-weight: 500;">curl -fsSL https://get.-weight: 500;">docker.com | sh
-weight: 600;">sudo usermod -aG -weight: 500;">docker $USER
newgrp -weight: 500;">docker # Install Git
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -weight: 500;">git -y # Install NVM + Node.js (LTS)
-weight: 500;">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/-weight: 500;">install.sh | bash
source ~/.bashrc
nvm -weight: 500;">install --lts
-weight: 500;">git clone -weight: 500;">git@github.com:yourusername/fastchat.-weight: 500;">git
-weight: 500;">git clone -weight: 500;">git@github.com:yourusername/infra.-weight: 500;">git
-weight: 500;">git clone -weight: 500;">git@github.com:yourusername/fastchat.-weight: 500;">git
-weight: 500;">git clone -weight: 500;">git@github.com:yourusername/infra.-weight: 500;">git
-weight: 500;">git clone -weight: 500;">git@github.com:yourusername/fastchat.-weight: 500;">git
-weight: 500;">git clone -weight: 500;">git@github.com:yourusername/infra.-weight: 500;">git
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
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
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
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 }
}
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 }
}
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 }
}
certbot: image: certbot/certbot volumes: - ./certbot/certs:/etc/letsencrypt - ./certbot/www:/var/www/certbot
certbot: image: certbot/certbot volumes: - ./certbot/certs:/etc/letsencrypt - ./certbot/www:/var/www/certbot
certbot: image: certbot/certbot volumes: - ./certbot/certs:/etc/letsencrypt - ./certbot/www:/var/www/certbot
-weight: 500;">docker compose up -d nginx # inside infra/ folder
-weight: 500;">docker compose up -d nginx # inside infra/ folder
-weight: 500;">docker compose up -d nginx # inside infra/ folder
-weight: 500;">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
-weight: 500;">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
-weight: 500;">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
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; }
}
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; }
}
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; }
}
# Start app and databases
cd ~/fastchat
-weight: 500;">docker compose up -d # Run PostgreSQL migrations
-weight: 500;">docker exec -e DATABASE_URL=<your-database-url> fastchat -weight: 500;">npm run migrate:up # Start Nginx
cd ~/infra
-weight: 500;">docker compose up -d nginx
# Start app and databases
cd ~/fastchat
-weight: 500;">docker compose up -d # Run PostgreSQL migrations
-weight: 500;">docker exec -e DATABASE_URL=<your-database-url> fastchat -weight: 500;">npm run migrate:up # Start Nginx
cd ~/infra
-weight: 500;">docker compose up -d nginx
# Start app and databases
cd ~/fastchat
-weight: 500;">docker compose up -d # Run PostgreSQL migrations
-weight: 500;">docker exec -e DATABASE_URL=<your-database-url> fastchat -weight: 500;">npm run migrate:up # Start Nginx
cd ~/infra
-weight: 500;">docker compose up -d nginx
GET https://fastchat.duckdns.org/health
GET https://fastchat.duckdns.org/health
GET https://fastchat.duckdns.org/health
{ "-weight: 500;">status": "OK", "version": "1.0.0", "checks": { "mongodb": "connected", "postgresql": "connected", "redis": "connected" }
}
{ "-weight: 500;">status": "OK", "version": "1.0.0", "checks": { "mongodb": "connected", "postgresql": "connected", "redis": "connected" }
}
{ "-weight: 500;">status": "OK", "version": "1.0.0", "checks": { "mongodb": "connected", "postgresql": "connected", "redis": "connected" }
}
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
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
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
location /.well-known/acme-challenge/ { root /var/www/certbot;
}
# location / {
# return 301 https://$host$request_uri; ← comment this out temporarily
# }
location /.well-known/acme-challenge/ { root /var/www/certbot;
}
# location / {
# return 301 https://$host$request_uri; ← comment this out temporarily
# }
location /.well-known/acme-challenge/ { root /var/www/certbot;
}
# location / {
# return 301 https://$host$request_uri; ← comment this out temporarily
# }
# -weight: 500;">start only nginx (run this every time)
-weight: 500;">docker compose up -d nginx # run certbot only once to issue certificate
-weight: 500;">docker compose run --rm certbot certonly ...
# -weight: 500;">start only nginx (run this every time)
-weight: 500;">docker compose up -d nginx # run certbot only once to issue certificate
-weight: 500;">docker compose run --rm certbot certonly ...
# -weight: 500;">start only nginx (run this every time)
-weight: 500;">docker compose up -d nginx # run certbot only once to issue certificate
-weight: 500;">docker compose run --rm certbot certonly ... - fastchat network — connects FastChat app to its databases
- shared gateway network — connects FastChat app to Nginx only - fastchat — the app itself (Node.js + 3 databases)
- infra — Nginx reverse proxy + Certbot for SSL - Port 22 — SSH
- Port 80 — HTTP
- Port 443 — HTTPS - 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 - ./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 -weight: 500;">enable HTTPS. - Set up CI/CD with GitHub Actions (auto-deploy on push to main)
- Add Prometheus metrics + Grafana dashboard
- Refactor into microservices (Auth -weight: 500;">service, Chat -weight: 500;">service, User -weight: 500;">service)
- Deploy to Kubernetes (EKS) - Joined Mar 21, 2026