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
- Pre-requisites
- Step 1: Networking — Build the VPC Foundation
- Step 2: Security Groups
- Step 3: Launch EC2 Instance
- Step 4: Install Prerequisites on EC2
- Step 5: Deploy the Application
- Step 6: Application Load Balancer
- Step 7: The Cleanup Problem
- Next Steps
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:
- 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
3. EC2 Key Pair
You'll need an EC2 key pair for SSH access:
- EC2 Console → Key Pairs → Create key pair
- Name:
apartment - Key pair type: RSA
- Private key format:
.pem - Download and save the
.pemfile 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/16CIDR (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 Console → Subnets → Create 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 → Actions → Edit 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 Console → Internet Gateways → Create internet gateway
| Setting | Value |
|---|---|
| Name tag | apartment-igw |
Click Create internet gateway.
Now attach it to the VPC:
Select apartment-igw → Actions → Attach to VPC → select apartment-vpc → Attach
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 Console → NAT Gateways → Create 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 Console → Route Tables → Create route table
| Setting | Value |
|---|---|
| Name | apartment-rt-public |
| VPC | apartment-vpc |
Click Create route table.
Add route to Internet Gateway:
Select apartment-rt-public → Routes tab → Edit routes → Add route
| Destination | Target |
|---|---|
0.0.0.0/0 |
Select Internet Gateway → apartment-igw
|
Click Save changes.
Associate public subnets:
Subnet associations tab → Edit subnet associations → select both apartment-public-a and apartment-public-b → Save associations
Private Route Table
VPC Console → Route Tables → Create route table
| Setting | Value |
|---|---|
| Name | apartment-rt-private |
| VPC | apartment-vpc |
Click Create route table.
Add route to NAT Gateway:
Select apartment-rt-private → Routes tab → Edit routes → Add route
| Destination | Target |
|---|---|
0.0.0.0/0 |
Select NAT Gateway → apartment-nat
|
Click Save changes.
Associate private subnets:
Subnet associations tab → Edit subnet associations → select both apartment-private-a and apartment-private-b → Save 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 Console → Security Groups → Create 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 Console → Security Groups → Create 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 Console → Instances → Launch 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.xrange - 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 Console → Endpoints → Create 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
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
Step 5: Deploy the Application
5.1 Clone the Repository
git clone https://github.com/Israel-dot-com/apartment-deployment-main
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)
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.
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
Generate a strong secret key:
openssl rand -hex 32
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
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
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
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
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
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
Check the backend logs to confirm migrations ran successfully:
docker compose logs backend --tail 20
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
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 Console → Load Balancing → Target Groups → Create 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 Console → Load Balancing → Load Balancers → Create load balancer → Application Load Balancer → Create
| 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).
Optional: Add HTTPS
If you have a domain name, you can add HTTPS with a free AWS certificate:
-
ACM Console (switch to
us-east-1if using CloudFront, otherwise use your ALB's region) → Request certificate → Request a public certificate - Add your domain name(s) → DNS validation → Request
- Click Create records in Route 53 to validate automatically
- Wait for status:
Issued - Go back to your ALB → Listeners → Add listener
- Protocol: HTTPS, Port: 443
- Default action: Forward to
apartment-tg - Default SSL/TLS certificate: Select your ACM certificate
- 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
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)