<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Naresh Lohar</title>
    <description>The latest articles on DEV Community by Naresh Lohar (@codephoenix86).</description>
    <link>https://dev.to/codephoenix86</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1470437%2F23da8a62-81d0-48f8-9f98-25bc8e4f878b.jpeg</url>
      <title>DEV Community: Naresh Lohar</title>
      <link>https://dev.to/codephoenix86</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codephoenix86"/>
    <language>en</language>
    <item>
      <title>How I Deployed My First Production App on AWS EC2 — Every Mistake I Made</title>
      <dc:creator>Naresh Lohar</dc:creator>
      <pubDate>Sat, 21 Mar 2026 12:26:50 +0000</pubDate>
      <link>https://dev.to/codephoenix86/how-i-deployed-my-first-production-app-on-aws-ec2-every-mistake-i-made-4e8e</link>
      <guid>https://dev.to/codephoenix86/how-i-deployed-my-first-production-app-on-aws-ec2-every-mistake-i-made-4e8e</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;FastChat is a REST + WebSocket chat API built with:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App:&lt;/strong&gt; Node.js, Express.js, Socket.io, MongoDB, PostgreSQL, Redis, AWS S3 (avatar storage), Jest + Supertest (testing)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt; Docker, Docker Compose, Nginx, AWS EC2, Let's Encrypt (SSL), DuckDNS (free domain)&lt;/p&gt;

&lt;p&gt;The live API is running at &lt;strong&gt;&lt;a href="https://fastchat.duckdns.org" rel="noopener noreferrer"&gt;https://fastchat.duckdns.org&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; 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 &lt;a href="https://github.com/codephoenix86" rel="noopener noreferrer"&gt;codephoenix86&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Before jumping into steps, here's how everything connects:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmagye98gp1gukl2nnqzd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmagye98gp1gukl2nnqzd.png" alt=" " width="800" height="691"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The key security decision: &lt;strong&gt;only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are exposed to the internet.&lt;/strong&gt; The databases and the app itself on port 3000 are completely internal — no one can reach them directly. All traffic must go through Nginx.&lt;/p&gt;

&lt;p&gt;I used two Docker networks to enforce this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fastchat network&lt;/strong&gt; — connects FastChat app to its databases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shared gateway network&lt;/strong&gt; — connects FastChat app to Nginx only&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step-by-Step: How I Deployed It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Build and Push Docker Image
&lt;/h3&gt;

&lt;p&gt;First, I built the Docker image of my chat app locally and pushed it to DockerHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; nareshlohar86/fastchat &lt;span class="nb"&gt;.&lt;/span&gt;
docker push nareshlohar86/fastchat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fey7b0ciphrud4xr69chl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fey7b0ciphrud4xr69chl.png" alt=" " width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the image is available anywhere, including my EC2 server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Write the Docker Compose Files
&lt;/h3&gt;

&lt;p&gt;I split my setup into &lt;strong&gt;two repositories&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;fastchat&lt;/strong&gt; — the app itself (Node.js + 3 databases)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;infra&lt;/strong&gt; — Nginx reverse proxy + Certbot for SSL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;fastchat/compose.yml&lt;/strong&gt; runs 4 services — fastchat, mongodb, postgres, redis — all connected via &lt;code&gt;fastchat&lt;/code&gt; network. FastChat is also connected to &lt;code&gt;shared-gateway&lt;/code&gt; so Nginx can reach it. Databases have no external port exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;infra/compose.yml&lt;/strong&gt; runs 2 services — nginx and certbot — both connected to &lt;code&gt;shared-gateway&lt;/code&gt;. Nginx is the only container with ports 80 and 443 exposed to the host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Launch an EC2 Instance
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkntjq49cx4ty03slzyia.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkntjq49cx4ty03slzyia.png" alt=" " width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I created a free AWS account, launched a &lt;strong&gt;t3.micro Ubuntu&lt;/strong&gt; instance, and downloaded the &lt;code&gt;.pem&lt;/code&gt; key pair for SSH access.&lt;/p&gt;

&lt;p&gt;In the Security Group, I opened exactly 3 ports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Port 22 — SSH&lt;/li&gt;
&lt;li&gt;Port 80 — HTTP&lt;/li&gt;
&lt;li&gt;Port 443 — HTTPS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6p1lspywonx1v1ccbza.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6p1lspywonx1v1ccbza.png" alt=" " width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you're new to AWS, here's the official guide: &lt;a href="https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-creating.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-creating.html&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4 — Set Up the Server
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fovxy394k1e1hck10l93i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fovxy394k1e1hck10l93i.png" alt=" " width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SSH into the instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/my-aws-key.pem ubuntu@&amp;lt;your-ec2-public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then install the tools needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install Docker (simplest method)&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
newgrp docker

&lt;span class="c"&gt;# Install Git&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;git &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install NVM + Node.js (LTS)&lt;/span&gt;
curl &lt;span class="nt"&gt;-o-&lt;/span&gt; https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
nvm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--lts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then clone both repositories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:yourusername/fastchat.git
git clone git@github.com:yourusername/infra.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;To authenticate with GitHub via SSH, generate a key pair on EC2 (&lt;code&gt;ssh-keygen&lt;/code&gt;) and add the public key to your GitHub account settings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5 — Set Up a Free Domain with DuckDNS
&lt;/h3&gt;

&lt;p&gt;To get HTTPS, you need a domain name. I used &lt;strong&gt;DuckDNS&lt;/strong&gt; — it's completely free.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://www.duckdns.org" rel="noopener noreferrer"&gt;https://www.duckdns.org&lt;/a&gt; and log in&lt;/li&gt;
&lt;li&gt;Claim a subdomain (e.g., &lt;code&gt;fastchat.duckdns.org&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Point it to your EC2 public IP address&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Facr8m5nrwp5nepyi3qxa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Facr8m5nrwp5nepyi3qxa.png" alt=" " width="800" height="205"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Done. Your app now has a real domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6 — Configure Nginx with Bind Mounts
&lt;/h3&gt;

&lt;p&gt;Since Nginx runs inside a Docker container, its filesystem is isolated. To let Nginx read my config files and SSL certificates, I used &lt;strong&gt;bind mounts&lt;/strong&gt; — they link a folder on the host machine to a path inside the container.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx/nginx.conf:/etc/nginx/nginx.conf:ro&lt;/span&gt;      &lt;span class="c1"&gt;# config (read-only)&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certbot/certs:/etc/letsencrypt&lt;/span&gt;                  &lt;span class="c1"&gt;# SSL certificates&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certbot/www:/var/www/certbot&lt;/span&gt;                    &lt;span class="c1"&gt;# for domain verification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My initial Nginx config listens on port 80 and serves the Let's Encrypt verification path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;fastchat.duckdns.org&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/.well-known/acme-challenge/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/certbot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# redirect everything else to HTTPS&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both Nginx and Certbot share the same volumes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;certbot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;certbot/certbot&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certbot/certs:/etc/letsencrypt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certbot/www:/var/www/certbot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the key to how domain verification works without downtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;./certbot/www:/var/www/certbot&lt;/code&gt; — Certbot writes the secret verification token here. Nginx serves it when Let's Encrypt sends a request to &lt;code&gt;/.well-known/acme-challenge/&lt;/code&gt;. Both containers read/write the same folder on the host machine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;./certbot/certs:/etc/letsencrypt&lt;/code&gt; — Once the certificate is issued, Certbot stores it here. Nginx reads the certificate from this same folder to enable HTTPS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7 — Get a Free SSL Certificate with Certbot
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Important: Start Nginx first, then run Certbot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;(I learned this the hard way — see Challenges below.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; nginx   &lt;span class="c"&gt;# inside infra/ folder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then issue the certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; certbot certonly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--webroot&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--webroot-path&lt;/span&gt; /var/www/certbot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--email&lt;/span&gt; your-email@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--agree-tos&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-eff-email&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; fastchat.duckdns.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How this works:&lt;/strong&gt; Let's Encrypt sends an HTTP request to your domain asking for a secret token. Certbot writes that token to &lt;code&gt;/var/www/certbot&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;After this succeeds, Certbot stores your certificate at &lt;code&gt;/etc/letsencrypt/live/fastchat.duckdns.org/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8 — Enable HTTPS in Nginx
&lt;/h3&gt;

&lt;p&gt;Update the Nginx config to add a second server block for port 443:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;fastchat.duckdns.org&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/fastchat.duckdns.org/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/fastchat.duckdns.org/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://fastchat:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 9 — Start Everything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start app and databases&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/fastchat
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Run PostgreSQL migrations&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your-database-url&amp;gt; fastchat npm run migrate:up

&lt;span class="c"&gt;# Start Nginx&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/infra
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff6w8dfl91pk57yaitwrr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff6w8dfl91pk57yaitwrr.png" alt=" " width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app is now live. You can verify with the health check endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://fastchat.duckdns.org/health
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mongodb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"postgresql"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"redis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjfbi0wx2kelll8s9en5v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjfbi0wx2kelll8s9en5v.png" alt=" " width="800" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhtu6s4kwy9r3xhhbskue.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhtu6s4kwy9r3xhhbskue.png" alt=" " width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges I Faced (The Real Learning)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Certbot failing because Nginx wasn't running&lt;/strong&gt;&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. App crashed on startup — forgot to update compose.yml after switching to S3&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;.env&lt;/code&gt; file locally — but forgot to add them to the &lt;code&gt;environment&lt;/code&gt; section in &lt;code&gt;compose.yml&lt;/code&gt;. So when the container started on EC2, the app crashed immediately because the S3 variables didn't exist inside the container.&lt;/p&gt;

&lt;p&gt;The fix was simply adding the missing variables to &lt;code&gt;compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fastchat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nareshlohar86/fastchat&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AWS_REGION=your_aws_region&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AWS_ACCESS_KEY_ID=your_aws_access_key_id&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;S3_BUCKET_NAME=your_s3_bucket_name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: whenever you add new environment variables to your app, update &lt;code&gt;compose.yml&lt;/code&gt; at the same time — don't leave it for later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Nginx crashed because FastChat wasn't running yet&lt;/strong&gt;&lt;br&gt;
I started the &lt;code&gt;infra&lt;/code&gt; compose before &lt;code&gt;fastchat&lt;/code&gt;. Nginx read its config, tried to resolve the &lt;code&gt;fastchat&lt;/code&gt; hostname via Docker DNS, failed because that container didn't exist yet, and crashed. Always start the app first, then the reverse proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A typo that took way too long to debug&lt;/strong&gt;&lt;br&gt;
I wrote &lt;code&gt;ssl_certificate&lt;/code&gt; twice instead of &lt;code&gt;ssl_certificate&lt;/code&gt; + &lt;code&gt;ssl_certificate_key&lt;/code&gt;. Nginx threw a cryptic error and I stared at it for too long. Read your config carefully, character by character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Forgot to run PostgreSQL migrations&lt;/strong&gt;&lt;br&gt;
Every time I redeployed with a fresh database, I forgot to run migrations. The &lt;code&gt;/auth/signup&lt;/code&gt; endpoint would fail with a table-not-found error. I've since added a reminder comment at the top of my deployment notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Certbot failing due to HTTP → HTTPS redirect&lt;/strong&gt;&lt;br&gt;
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 &lt;code&gt;/.well-known/acme-challenge/&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;The fix was to temporarily remove the HTTPS redirect from Nginx config, leaving only the acme-challenge block on port 80:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/.well-known/acme-challenge/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/certbot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# location / {&lt;/span&gt;
&lt;span class="c1"&gt;#     return 301 https://$host$request_uri;   ← comment this out temporarily&lt;/span&gt;
&lt;span class="c1"&gt;# }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get the certificate first, then uncomment the redirect and reload Nginx.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Accidentally starting Certbot container along with Nginx&lt;/strong&gt;&lt;br&gt;
My &lt;code&gt;infra/compose.yml&lt;/code&gt; has two services — nginx and certbot. Early on I kept running &lt;code&gt;docker compose up -d&lt;/code&gt; inside the infra folder which starts &lt;strong&gt;both&lt;/strong&gt; services together. But certbot is a one-time job — it just issues the certificate and exits. Running it repeatedly caused unnecessary errors and confusion.&lt;/p&gt;

&lt;p&gt;The fix was to start them separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# start only nginx (run this every time)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; nginx

&lt;span class="c"&gt;# run certbot only once to issue certificate&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; certbot certonly ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: use &lt;code&gt;docker compose up -d &amp;lt;service-name&amp;gt;&lt;/code&gt; to start a specific service instead of all services at once. Certbot is not a long-running service — it does its job and exits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Before this, I thought deploying meant just running &lt;code&gt;node index.js&lt;/code&gt; on a server somewhere. I didn't appreciate how much infrastructure sits between your code and the internet.&lt;/p&gt;

&lt;p&gt;The biggest mental shift: &lt;strong&gt;your app should not care about SSL, ports, or the outside world.&lt;/strong&gt; Nginx handles all of that. Your app just receives clean HTTP requests on an internal port. This separation makes everything simpler and more secure.&lt;/p&gt;

&lt;p&gt;The Docker networking model also clicked for me here — different networks for different trust boundaries. Databases don't need to know the internet exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set up CI/CD with GitHub Actions (auto-deploy on push to main)&lt;/li&gt;
&lt;li&gt;Add Prometheus metrics + Grafana dashboard&lt;/li&gt;
&lt;li&gt;Refactor into microservices (Auth service, Chat service, User service)&lt;/li&gt;
&lt;li&gt;Deploy to Kubernetes (EKS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;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 &lt;a href="https://www.linkedin.com/in/nareshlohar86/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or check out my code on &lt;a href="https://github.com/codephoenix86" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>webdev</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
