DEV Community

Naresh Lohar
Naresh Lohar

Posted on

How I Deployed My First Production App on AWS EC2 — Every Mistake I Made

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
Enter fullscreen mode Exit fullscreen mode

Now the image is available anywhere, including my EC2 server.

Step 2 — Write the Docker Compose Files

I split my setup into two repositories:

  1. fastchat — the app itself (Node.js + 3 databases)
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then clone both repositories:

git clone git@github.com:yourusername/fastchat.git
git clone git@github.com:yourusername/infra.git
Enter fullscreen mode Exit fullscreen mode

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.

  1. Go to https://www.duckdns.org and log in
  2. Claim a subdomain (e.g., fastchat.duckdns.org)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

Both Nginx and Certbot share the same volumes:

certbot:
  image: certbot/certbot
  volumes:
    - ./certbot/certs:/etc/letsencrypt
    - ./certbot/www:/var/www/certbot
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The app is now live. You can verify with the health check endpoint:

GET https://fastchat.duckdns.org/health
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "status": "OK",
  "version": "1.0.0",
  "checks": {
    "mongodb": "connected",
    "postgresql": "connected",
    "redis": "connected"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
# }
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
ashh_py profile image
Aishwary Bhatt

great! naresh.. i think this post will help me in deploying my project

Collapse
 
nikhil_nagar_92212c1202d4 profile image
NIKHIL Nagar

Good work naresh

Collapse
 
dev_singhraghunandan_474 profile image
Dev Singh Raghunandan

Amazing insights

Collapse
 
anmol_tripathi_2f3d5f4125 profile image
Anmol Tripathi

Now you’re officially a cloud engineer😎

Collapse
 
codephoenix86 profile image
Naresh Lohar

😅