DEV Community

Israel
Israel

Posted on Edited on AI-assisted

Deploying a Full-Stack Production Application on AWS How-To-Guide

REPO:https://github.com/Israel-dot-com/apartment-deployment-main

This is the first article in my DevOps Porfolio series. We'll deploy a Full Stack Application, It currently runs perfectly locally with docker compose up.

Now we want it on the internet.

It's a React + FastAPI + PostgreSQL project, to get it up and running via the cloud, We'll need to create a proper isolated network, a load balancer, and security groups & we'll do it all through the AWS Console, it looks tedious and there has to be a better way but we'll persevere through it.

Table of Contents


Introduction

I'll be using a previous unfinished project, a hospitality and car rental marketplace. Vendors list short-let apartments and vehicles, complete identity verification, and manage bookings. Admins moderate listings and track analytics through a dashboard. Users browse, book, and manage support tickets.

The architecture looks like this:

Network Diagram

  • A custom VPC with public and private subnets across 2 availability zones
  • Two EC2 instances running the Docker Compose stack in a private subnet
  • An Application Load Balancer handling all incoming traffic
  • Layered security groups so the EC2 never communicates directly with the internet
  • A fully functional application accessible via the ALB DNS name

Pre-requisites

Before deploying, make sure the following are ready:

1. AWS Account

  • An AWS account with administrative access
  • AWS CLI configured (optional — we'll use the Console for everything)

2. Application Code

The application repository with the Docker Compose configuration:

git clone https://github.com/Israel-dot-com/apartment-deployment-main
Enter fullscreen mode Exit fullscreen mode

3. EC2 Key Pair

You'll need an EC2 key pair for SSH access:

  • EC2 ConsoleKey PairsCreate key pair
  • Name: apartment
  • Key pair type: RSA
  • Private key format: .pem
  • Download and save the .pem file securely

Step 1: Networking — Build the VPC Foundation

Overview

We are going to create a custom VPC with public and private subnets across 2 availability zones for the application. Using the default VPC would put everything in public subnets with wide-open routing — fine for a quick test, not suitable for anything production-facing.

What we'll create:

We can create these a the same time from one interface on the Create VPC console.

  • VPC — Virtual Private Cloud with 10.0.0.0/16 CIDR (65,536 IPs)
  • Public Subnets — 2 subnets for internet-facing resources (ALB, NAT Gateway)
  • Private Subnets — 2 subnets for the application server
  • Internet Gateway — Internet access for public subnets
  • NAT Gateway — Outbound-only internet for private subnets (Docker pulls, system updates)
  • Route Tables — Traffic routing configuration

1.1 Create the VPC


1.2 Create the Subnets

We need 4 subnets across 2 availability zones. The ALB requires subnets in at least 2 AZs, and the second private subnet provides room for future scaling.

VPC ConsoleSubnetsCreate subnet

Select VPC: apartment-vpc

Public Subnet A:

Setting Value
Subnet name apartment-public-a
Availability Zone us-east-1a
IPv4 CIDR block 10.0.1.0/24

Click Add new subnet to add the remaining three in one go:

Public Subnet B:

Setting Value
Subnet name apartment-public-b
Availability Zone us-east-1b
IPv4 CIDR block 10.0.2.0/24

Private Subnet A:

Setting Value
Subnet name apartment-private-a
Availability Zone us-east-1a
IPv4 CIDR block 10.0.11.0/24

Private Subnet B:

Setting Value
Subnet name apartment-private-b
Availability Zone us-east-1b
IPv4 CIDR block 10.0.12.0/24

Click Create subnet.

Enable auto-assign public IPv4 for public subnets:

For each public subnet (apartment-public-a and apartment-public-b):

  • Select the subnet → ActionsEdit subnet settings
  • Check Enable auto-assign public IPv4 address
  • Save


1.3 Create and Attach the Internet Gateway

The Internet Gateway gives your public subnets a path to the internet. Without it, nothing in the VPC can reach the outside world.

VPC ConsoleInternet GatewaysCreate internet gateway

Setting Value
Name tag apartment-igw

Click Create internet gateway.

Now attach it to the VPC:

Select apartment-igwActionsAttach to VPC → select apartment-vpcAttach

Verify:

  • State should show: Attached
  • Attached VPC: apartment-vpc

1.4 Create the NAT Gateway

The EC2 instance lives in a private subnet, no direct internet access. But it still needs to pull Docker images, install system packages, and download updates. The NAT Gateway provides outbound-only internet access. Traffic goes out, but nothing can initiate a connection in.

VPC ConsoleNAT GatewaysCreate NAT gateway

Setting Value
Name apartment-nat
Subnet apartment-public-a (must be a public subnet)
Connectivity type Public
Elastic IP allocation ID Click Allocate Elastic IP

Click Create NAT gateway.

Important: The NAT Gateway takes 1-2 minutes to become available. Wait for the status to change from Pending to Available before proceeding to route tables.

Save This Value:

  • Elastic IP address (you'll need this for cleanup)

1.5 Create and Configure Route Tables

We need two route tables with different routing rules:

  • Public route table — sends internet traffic through the Internet Gateway
  • Private route table — sends internet traffic through the NAT Gateway

Public Route Table

VPC ConsoleRoute TablesCreate route table

Setting Value
Name apartment-rt-public
VPC apartment-vpc

Click Create route table.

Add route to Internet Gateway:

Select apartment-rt-publicRoutes tab → Edit routesAdd route

Destination Target
0.0.0.0/0 Select Internet Gatewayapartment-igw

Click Save changes.

Associate public subnets:

Subnet associations tab → Edit subnet associations → select both apartment-public-a and apartment-public-bSave associations

Private Route Table

VPC ConsoleRoute TablesCreate route table

Setting Value
Name apartment-rt-private
VPC apartment-vpc

Click Create route table.

Add route to NAT Gateway:

Select apartment-rt-privateRoutes tab → Edit routesAdd route

Destination Target
0.0.0.0/0 Select NAT Gatewayapartment-nat

Click Save changes.

Associate private subnets:

Subnet associations tab → Edit subnet associations → select both apartment-private-a and apartment-private-bSave associations

[screenshot: route tables showing public→IGW and private→NAT routes]

Verify — Route Table Validation:

Route Table Route 0.0.0.0/0 Associated Subnets
apartment-rt-public apartment-igw apartment-public-a, apartment-public-b
apartment-rt-private apartment-nat apartment-private-a, apartment-private-b

That's the entire network foundation done. We have a VPC with proper separation between public and private resources, internet access through the IGW, and outbound-only access through the NAT for our private subnets.


Step 2: Security Groups

Overview

We need two security groups that work together as layers. The key principle: the EC2 instance only accepts traffic from the ALB, never directly from the internet.


2.1 Create ALB Security Group

This security group allows HTTP and HTTPS traffic from anywhere — the ALB is the public-facing entry point.

VPC ConsoleSecurity GroupsCreate security group

Setting Value
Security group name apartment-sg-alb
Description Allow HTTP/HTTPS inbound to ALB
VPC apartment-vpc

Inbound rules — Add rules:

Type Port Source Description
HTTP 80 0.0.0.0/0 HTTP from internet
HTTPS 443 0.0.0.0/0 HTTPS from internet

Outbound rules: Leave default (All traffic → 0.0.0.0/0)

Click Create security group.


2.2 Create EC2 Security Group

This security group only allows traffic from the ALB — not from the public internet. This is the key security benefit of the architecture.

VPC ConsoleSecurity GroupsCreate security group

Setting Value
Security group name apartment-sg-ec2
Description Allow traffic from ALB and SSH
VPC apartment-vpc

Inbound rules — Add rules:

Type Port Source Description
HTTP 80 Select apartment-sg-alb (security group) Traffic from ALB only
SSH 22 My IP (or your specific IP/32) SSH access

Outbound rules: Leave default (All traffic → 0.0.0.0/0)

Click Create security group.

Important: For the HTTP rule, the source is the ALB security group ID, not a CIDR block. This means even if someone discovers the EC2's private IP address, they cannot reach it directly. All traffic must flow through the load balancer.

[screenshot: EC2 security group showing port 80 source as apartment-sg-alb]

Verify — Security Group Validation:

Security Group Inbound Rules
apartment-sg-alb 80 from 0.0.0.0/0, 443 from 0.0.0.0/0
apartment-sg-ec2 80 from apartment-sg-alb, 22 from your IP


Step 3: Launch EC2 Instance

3.1 Launch the Instance

EC2 ConsoleInstancesLaunch instances

Setting Value
Name apartment-server
Application and OS Images Ubuntu Server 24.04 LTS (HVM), SSD
Architecture 64-bit (x86)
Instance type t3.medium
Key pair apartment (created in pre-requisites)

Network settings → Click Edit:

Setting Value
VPC apartment-vpc
Subnet apartment-private-a
Auto-assign public IP Disable
Security group Select existing → apartment-sg-ec2

Configure storage:

Setting Value
Size 30 GiB
Volume type gp3
Encrypted Yes

Click Launch instance.

Verify:

  • Instance state: Running
  • Private IPv4: Should be in the 10.0.10.x range
  • Public IPv4: None (this is correct — it's in a private subnet)
  • Security group: apartment-sg-ec2

3.2 SSH Access via Instance Connect Endpoint

Since the EC2 is in a private subnet with no public IP, we cannot SSH directly. We'll use an EC2 Instance Connect Endpoint — no bastion host required, no public IP needed.

VPC ConsoleEndpointsCreate endpoint

Setting Value
Name tag apartment-eice
Service category EC2 Instance Connect Endpoint
VPC apartment-vpc
Subnet apartment-private-a
Security group apartment-sg-ec2

Click Create endpoint. Wait for the status to become Available (takes 2-3 minutes).

Connect to the instance via the Instance Endpoint Connect Endpoint


Step 4: Install Prerequisites on EC2

All we need on the server is Docker, Docker Compose, and Git. We can use this script to install everything:

#!/bin/bash

# Update system
sudo apt update && sudo apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com | sudo sh

# Allow current user to run Docker without sudo
sudo usermod -aG docker $USER
newgrp docker

# Install Git
sudo apt install -y git

# Verify installations
docker --version
docker compose version
git --version
Enter fullscreen mode Exit fullscreen mode

Save this as install.sh, give it permission with chmod +x install.sh, and run it. Or just paste the commands directly.

Verify Installation:

docker --version
# Docker version 28.x.x

docker compose version
# Docker Compose version v2.x.x

git --version
# git version 2.x.x
Enter fullscreen mode Exit fullscreen mode

Step 5: Deploy the Application

5.1 Clone the Repository

git clone https://github.com/Israel-dot-com/apartment-deployment-main
Enter fullscreen mode Exit fullscreen mode

The repository structure:

apartment-deployment/
├── be-apartment/          # FastAPI backend
│   ├── api/               # Routes, models, schemas, services
│   ├── alembic/           # Database migrations
│   ├── Dockerfile
│   ├── .env.sample        # Example backend config
│   └── entrypoint.sh      # Runs migrations then starts Uvicorn
├── fe-apartment/          # React frontend
│   ├── src/
│   └── Dockerfile         # Multi-stage build → Nginx
├── nginx/                 # Reverse proxy config
│   └── nginx.conf
├── docker-compose.yml     # Orchestrates all services
└── .env                   # Root env (DB credentials)
Enter fullscreen mode Exit fullscreen mode

5.2 Configure Environment Files

Here's the first gotcha. .env files are in .gitignore, as they should be, so they don't exist after cloning. If you skip this step and run docker compose up directly, you'll see:

WARN[0000] The "DB_NAME" variable is not set. Defaulting to a blank string.
WARN[0000] The "DB_USER" variable is not set. Defaulting to a blank string.
WARN[0000] The "DB_PASSWORD" variable is not set. Defaulting to a blank string.
Enter fullscreen mode Exit fullscreen mode

PostgreSQL starts with blank credentials, fails its health check, and every dependent service collapses. Not helpful.

Create the root .env (used by the PostgreSQL service in docker-compose.yml):

cat > .env << 'EOF'
PYTHON_ENV=prod

DB_TYPE=postgresql
DB_NAME=apartment
DB_USER=apartment
DB_PASSWORD=YourSecurePasswordHere

SECRET_KEY=generate-a-random-string-here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=600
JWT_REFRESH_EXPIRY=30

APP_URL=http://localhost
FRONTEND_URL=http://localhost
EOF
Enter fullscreen mode Exit fullscreen mode

Generate a strong secret key:

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

Use the output as your SECRET_KEY and DB_PASSWORD values.

Create the backend .env (used by the FastAPI application):

Copy the sample file and update it:

cp be-apartment/.env.sample be-apartment/.env
nano be-apartment/.env
Enter fullscreen mode Exit fullscreen mode

At minimum, make sure these values are set and match the root .env:

DB_URL=postgresql://apartment:YourSecurePasswordHere@postgres:5432/apartment
SECRET_KEY=generate-a-random-string-here
Enter fullscreen mode Exit fullscreen mode

Important: The DB_PASSWORD in the root .env must match the password in the DB_URL in be-apartment/.env. The hostname in DB_URL must be postgres — this is the Docker Compose service name, not localhost.


5.3 Build and Start the Stack

docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

The first build takes a few minutes — Docker is pulling base images, installing Python dependencies (pip install), building the React frontend (npm run build), and creating all four container images. Subsequent builds use Docker's cache and are much faster.

Wait about 30 seconds for health checks to complete, then check the status:

docker compose ps
Enter fullscreen mode Exit fullscreen mode

Expected output:

NAME                    STATUS          PORTS
apartment-postgres      Up (healthy)    5432/tcp
apartment-backend-1     Up (healthy)    8000/tcp
apartment-frontend      Up              80/tcp
apartment-nginx         Up              0.0.0.0:80->80/tcp
Enter fullscreen mode Exit fullscreen mode

All four services should show Up with the postgres and backend containers showing (healthy).

NAME                    STATUS          PORTS
apartment-postgres      Up (healthy)    5432/tcp
apartment-backend-1     Up (healthy)    8000/tcp
apartment-frontend      Up              80/tcp
apartment-nginx         Up              0.0.0.0:80->80/tcp
Enter fullscreen mode Exit fullscreen mode

Check the backend logs to confirm migrations ran successfully:

docker compose logs backend --tail 20
Enter fullscreen mode Exit fullscreen mode
backend-1  | Running database migrations...
backend-1  | INFO  [alembic.runtime.migration] Running upgrade  -> 22962e293a83, apply existing migrations
backend-1  | INFO  [alembic.runtime.migration] Running upgrade 22962e293a83 -> a1b2c3d4e5f6, add phone_number and avatar_url
backend-1  | ...
backend-1  | INFO  [alembic.runtime.migration] Running upgrade 486419238951 -> c8f1b2e9a7d0, set property status default
backend-1  | Starting FastAPI...
backend-1  | INFO:     Uvicorn running on http://0.0.0.0:8000
Enter fullscreen mode Exit fullscreen mode

All migrations applied. FastAPI is running. But we still can't access the application from a browser — the EC2 is in a private subnet with no public IP. We need the load balancer.


Step 6: Application Load Balancer

Overview

The ALB sits in the public subnets and forwards traffic to the EC2 in the private subnet. This is how the application becomes accessible from the internet while keeping the server isolated.


6.1 Create Target Group

The target group tells the ALB where to send traffic and how to check if the target is healthy.

EC2 ConsoleLoad BalancingTarget GroupsCreate target group

Setting Value
Target type Instances
Target group name apartment-tg
Protocol HTTP
Port 80
VPC apartment-vpc
Protocol version HTTP1

Health checks:

Setting Value
Health check protocol HTTP
Health check path /
Healthy threshold 3
Unhealthy threshold 3
Timeout 10 seconds
Interval 30 seconds
Success codes 200-399

Click Next.

Register targets:

Select your apartment-server instance → click Include as pending below → click Create target group.

Wait 30-60 seconds, then verify the target shows as healthy in the Targets tab.


6.2 Create the ALB

EC2 ConsoleLoad BalancingLoad BalancersCreate load balancerApplication Load BalancerCreate

Setting Value
Load balancer name apartment-alb
Scheme Internet-facing
IP address type IPv4

Network mapping:

Setting Value
VPC apartment-vpc
Mappings Select both: apartment-public-a (us-east-1a) and apartment-public-b (us-east-1b)

Security groups:

Remove the default security group. Select apartment-sg-alb.

Listeners and routing:

Protocol Port Default action
HTTP 80 Forward to apartment-tg

Click Create load balancer.

Wait for the ALB state to change from Provisioning to Active (takes 2-3 minutes).

ALB Creation

Optional: Add HTTPS

If you have a domain name, you can add HTTPS with a free AWS certificate:

  1. ACM Console (switch to us-east-1 if using CloudFront, otherwise use your ALB's region) → Request certificateRequest a public certificate
  2. Add your domain name(s) → DNS validation → Request
  3. Click Create records in Route 53 to validate automatically
  4. Wait for status: Issued
  5. Go back to your ALB → ListenersAdd listener
    • Protocol: HTTPS, Port: 443
    • Default action: Forward to apartment-tg
    • Default SSL/TLS certificate: Select your ACM certificate
  6. Edit the HTTP:80 listener → change action to Redirect to HTTPS port 443

6.3 Test the Application

Open the ALB DNS name in your browser:

http://apartment-alb-1060582062.us-east-1.elb.amazonaws.com
Enter fullscreen mode Exit fullscreen mode

Frontend loads
Backend API responds at /api/v1/
User registration and login


Step 7: The Cleanup Problem

The application is deployed and working. But now let's say we're done testing and want to tear everything down to stop paying for it.

Here's what we have to delete, manually, in the correct dependency order:

# Resource Console Location Wait Required?
1 EC2 Instance EC2 → Instances → Terminate Wait for termination
2 Load Balancer EC2 → Load Balancers → Delete
3 Target Group EC2 → Target Groups → Delete
4 NAT Gateway VPC → NAT Gateways → Delete ⏳ Wait 1-2 minutes
5 Elastic IP VPC → Elastic IPs → Release Must wait for NAT GW deletion
6 Instance Connect Endpoint VPC → Endpoints → Delete ⏳ Wait 2-3 minutes
7 Internet Gateway VPC → IGWs → Detach → Delete Must detach before deleting
8 Security Groups VPC → Security Groups → Delete Can't delete while ALB/EC2 exist
9 Subnets VPC → Subnets → Delete
10 Route Tables VPC → Route Tables → Delete Must remove associations first
11 VPC VPC → Your VPCs → Delete Everything above must be gone

You have to delete them in order or the VPC deletion fails with a dependency error. Delete things in the wrong order and you get DependencyViolation: resource has a dependent object. Try to delete the NAT Gateway and the Elastic IP at the same time and the EIP release fails because the NAT Gateway is still in the deleting state.

I spent a lot of time manually creating resources through the console, waiting for resources to finish deleting, and retrying failed deletions.

It's tedious doing this every time you need to to scaffold the entire architecture. That's where Terraform comes in, Terraform is an infrastructure as code that lets you build, change, and version our target infrastructure safely and efficiently, in our next article we'll look into spinning up this project with Terraform

Top comments (0)