Introduction
Microservices have revolutionized the way we build scalable and maintainable applications. Instead of a monolithic structure, microservices break an application into smaller, independent services that communicate efficiently. This approach enhances flexibility, improves fault isolation, and makes deployments smoother.
In this post, Iβll walk you through how I structured my microservices project using FastAPI and Docker. Iβll cover the folder structure, best practices for organizing services, and how Docker Compose simplifies managing multiple services. Whether youβre migrating from a monolith or starting fresh with microservices, this guide will help you build a well-structured, scalable architecture.
Letβs dive in! ![]()
Microservices vs. Nano-services: A Detailed Comparison
When designing a cloud-native application, you have multiple architectural choices. Two common approaches are Microservice Architecture (Docker-based) and Nano-service Architecture (AWS Lambda/API Gateway-based). Letβs compare these two approaches in key areas:
1. Service Granularity
- Microservices (Docker-based): Services are independent but still contain multiple functionalities grouped logically (e.g., user authentication, payments, notifications).
- Nano-services (Lambda/API Gateway): Functions are broken down into the smallest possible units, where each function performs a single, specific task.
2. Deployment & Management
- Microservices: Deployed as containers using Docker, managed via Kubernetes, or orchestrated with Docker Compose.
- Nano-services: Each function is deployed separately as an AWS Lambda, and API Gateway is used for routing and managing requests.
3. Scalability
- Microservices: Can scale horizontally by adding more containers. Requires a load balancer and service discovery mechanism.
- Nano-services: Automatically scales up or down based on demand. AWS handles scaling without manual intervention.
4. Performance & Latency
- Microservices: Lower latency since services are running continuously in containers.
- Nano-services: Higher cold start latency since AWS Lambda needs to spin up a function when it hasnβt been used recently.
5. Cost Efficiency
- Microservices: Requires provisioning of compute resources (EC2, ECS, or Kubernetes), leading to potential over-provisioning costs.
- Nano-services: Pay-as-you-go pricing, only charging for the execution time of each Lambda function, reducing idle costs.
6. Maintenance & Complexity
- Microservices: More complex to manage due to container orchestration, networking, and inter-service communication.
- Nano-services: Simpler management as AWS handles scaling, infrastructure, and maintenance.
7. Long-term Cost Comparison
- Microservices: Long-term costs depend on infrastructure choices. Running services 24/7 on ECS, EKS, or EC2 incurs fixed costs, and Kubernetes clusters add operational overhead. For high-load applications, microservices may become more cost-effective due to predictable pricing.
- Nano-services: AWS Lambda has a pay-per-use model, making it cost-effective for infrequent or spiky workloads. However, for high-volume, always-on applications, Lambda costs can scale up significantly due to execution time and request-based pricing.
8. Cost & Scalability Example for 1 Million Users
- Microservices: If each user generates 10 requests per day, thatβs 10 million requests daily. Running microservices on an EKS or ECS cluster with 10 EC2 instances (c5.large, 2 vCPUs, 4GB RAM each) would cost around $800-$1000/month, including load balancing, database, and networking.
- Nano-services: AWS Lambda pricing is based on execution time. Assuming each function runs for 100ms and each request triggers 3 Lambda functions, this results in 30 million invocations/day. With 1 million users, this would cost around $1500-$2000/month due to API Gateway, Lambda execution time, and data transfer costs.
- Key Takeaway: Microservices provide better cost predictability for sustained high traffic, whereas Nano-services work best for unpredictable, spiky workloads.
9. Use Cases
- Microservices: Ideal for large, stateful applications requiring persistent connections (e.g., chat applications, financial systems, and e-commerce platforms).
- Nano-services: Best for event-driven, lightweight tasks like scheduled jobs, data processing, and webhook-based automation.
Starting with Microservices: Folder Structure
Now that weβve compared architectures, letβs dive into structuring a Docker-based Microservices project. Hereβs the folder structure I use:
.
βββ docker-compose.yaml
βββ microservice
βββ Dockerfile
βββ main.py
βββ models
β βββ merchant_user.py
βββ README.Docker.md
βββ requirements.txt
βββ services
β βββ merchant_details
β β βββ main.py
β β βββ register.py
β β βββ users.py
β βββ send_emails
β βββ main.py
β βββ send_email.py
β
βββ utils
βββ config.py
βββ db_helper.py
βββ helper.py
βββ models.py
βββ schema.py
βββ user_helper.py
Microservice Architecture Folder Structure
Main Entry Point: main.py
from services.merchant_details import users
from services.user_auth import auth, google_auth, register
from services.send_emails import send_email
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
import os
load_dotenv()
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key= os.environ['SECRET_KEY']) # Use a secure, random secret key
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(google_auth.router)
app.include_router(register.router)
app.include_router(send_email.router)
Dependencies: requirements.txt
Run the following command to install dependencies:
pip install -r requirements.txt
Contents of requirements.txt
fastapi[all]
mysql-connector-python
passlib[bcrypt]
pyjwt
authlib
sqlalchemy
psycopg2-binary
alembic
boto3
services Directory
merchant_details
merchant_details/main.py
from services.merchant_details import users, register
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
import os
load_dotenv()
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key= os.environ['SECRET_KEY']) # Use a secure, random secret key
app.include_router(users.router)
app.include_router(register.router)
merchant_details/register.py
from email import message
from typing import Annotated
from fastapi import Depends, Request, APIRouter, status,HTTPException
import json
from utils.helper import generate_user_id
from utils.schema import userRegisterModel
from utils.db_helper import connect_to_db
from utils.user_helper import check_user_exist, add_user_to_db
from passlib.context import CryptContext
import re
# Returns the hashed password string
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password):
return pwd_context.hash(password)
router = APIRouter()
def validate_password(password: str):
"""Check if the password meets security requirements."""
if not re.fullmatch(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$', password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long, include at least one uppercase letter, one lowercase letter, one number, and one special character."
)
@router.post("/register")
def register_user(user: userRegisterModel):
try:
conn = connect_to_db()
cursor = conn.cursor()
user_exist = check_user_exist(cursor, user.email, 'merchant_user')
if user_exist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
if user.confirm_password != user.password:
raise HTTPException(status_code= status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
# Validate password
validate_password(user.password)
hashed_password = get_password_hash(user.password)
print('hashed_password ', hashed_password)
user_details = {
'email': user.email,
'user_id': generate_user_id('Other'),
'table_name': 'merchant_user',
'first_name':user.first_name,
'last_name': user.last_name,
'middle_name': user.middle_name,
'profile_picture': None
}
print('user_details ',user_details)
add_user_to_db(
conn, cursor,
'merchant_user',
user_details['user_id'],
user_details['email'],
user_details['profile_picture'],
user_details['first_name'],
user_details.get('middle_name'),
user_details.get('last_name'),
hashed_password
)
return {
'statusCode': 201,
'body': json.dumps({'message':"Successfully created"})
}
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
merchant_details/users.py
from typing import Annotated
from utils.schema import User
from fastapi import Depends, APIRouter
from utils.db_helper import connect_to_db
from ..user_auth.auth import get_current_active_user
router = APIRouter()
@router.get("/users/me/")
async def read_own_items(
current_user: Annotated[User, Depends(get_current_active_user)]
):
conn = connect_to_db()
cursor = conn.cursor()
query = "SELECT * FROM users;"
cursor.execute(query)
users = cursor.fetchall()
cursor.close()
conn.close()
return {"users": users}
@router.get("/users/me/public")
async def read_own_items():
conn = connect_to_db()
cursor = conn.cursor()
query = "SELECT * FROM users;"
cursor.execute(query)
users = cursor.fetchall()
cursor.close()
conn.close()
return {"users": users}
@router.get("/users/auth")
async def read_own_items(
current_user_data: Annotated[dict, Depends(get_current_active_user)]
):
user = current_user_data["user"]
auth_method = current_user_data["auth_method"]
return {
"message": f"Authenticated via {auth_method}",
"user": user
}
send_emails
send_emails/main.py
from services.send_emails import send_email
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
import os
load_dotenv()
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key= os.environ['SECRET_KEY']) # Use a secure, random secret key
app.include_router(send_email.router)
send_emails/send_email.py
import json
import boto3
from fastapi import FastAPI, HTTPException, APIRouter
from utils.schema import EmailSchema
from dotenv import load_dotenv
import os
load_dotenv()
router = APIRouter()
AWS_REGION = os.getenv("AWS_REGION")
AWS_ACCESS_KEY = os.getenv("AWS_ACCESS_KEY")
AWS_SECRET_KEY = os.getenv("AWS_SECRET_KEY")
ses_client = boto3.client(
"ses",
region_name=AWS_REGION,
aws_access_key_id=AWS_ACCESS_KEY,
aws_secret_access_key=AWS_SECRET_KEY,
)
def verify_email(email):
try:
response = ses_client.verify_email_identity(
EmailAddress=email
)
print(f"Verification email sent to {email}. Please check your inbox.")
return response
except Exception as e:
print(f"Error: {e}")
def send_email(email_content):
to_email = email_content.email
subject = email_content.subject
otp = email_content.otp
user_id = email_content.user_id
try:
response = ses_client.send_templated_email(
Source="no-reply@reistta.com",
Destination={"ToAddresses": [to_email]},
Template="dev-otp", # Replace with your actual template name
TemplateData=json.dumps({
"user_id": user_id,
"OTP": otp
})
)
print(response)
return {"status": "success", "message": "Email sent successfully", "MessageId": response["MessageId"]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/send-email/")
async def send_email_api(email_content: EmailSchema):
return send_email(email_content)
Next, we will include the user_auth service details.
Utility Functions
For utility functions like authentication and database connection, refer to my Medium post: Google Authentication in FastAPI using OAuth2.
DockerFile
# Use a lightweight Python image
FROM python:3.10-slim as base
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set the working directory inside the container
WORKDIR /app
# Create a non-root user
ARG UID=10001
RUN adduser --disabled-password --gecos "" --uid "${UID}" appuser
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ../.env .env
# Copy application code
COPY . .
# Change ownership to the non-root user
RUN chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose the default FastAPI port
EXPOSE 8000
# Use a dynamic CMD to allow flexibility in each microservice
CMD ["sh", "-c", "$CMD"]
Docker Compose Configuration
The docker-compose.yaml file defines the services and how they interact with each other.
docker-compose.yaml
version: '3.8'
services:
register:
build:
context: ./microservice
dockerfile: Dockerfile
command: uvicorn routers.user_auth.main:app --host 0.0.0.0 --port 8003
ports:
- "8003:8003"
depends_on:
db:
condition: service_healthy
env_file:
- ./microservice/.env
networks:
- app-network
merchant_details:
build:
context: ./microservice
dockerfile: Dockerfile
command: uvicorn services.merchant_details.main:app --host 0.0.0.0 --port 8004
ports:
- "8004:8004"
depends_on:
db:
condition: service_healthy
env_file:
- ./microservice/.env
networks:
- app-network
send_emails:
build:
context: ./microservice
dockerfile: Dockerfile
command: uvicorn routers.send_emails.main:app --host 0.0.0.0 --port 8005
ports:
- "8005:8005"
depends_on:
db:
condition: service_healthy
env_file:
- ./microservice/.env
networks:
- app-network
db:
image: mysql:latest
restart: always
env_file:
- ./microservice/.env
ports:
- "3306:3306"
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
volumes:
db-data:
networks:
app-network:
driver: bridge
Explanation of docker-compose.yaml
- register, merchant_details, send_emails:
- Each service is built using the same Dockerfile but runs a different FastAPI module.
- The
commandspecifies which module to run with Uvicorn. - Services expose different ports (8003, 8004, 8005) for communication.
- They depend on the
dbservice to be healthy before starting. - Environment variables are loaded from
.env. - Connected to
app-networkfor inter-service communication.
- db:
- Uses the latest MySQL image.
- Loads environment variables from
.env. - Exposes MySQL on port 3306.
- Includes a health check to ensure MySQL is running before other services start.
- Uses a named volume (
db-data) for persistent storage.
- Networks & Volumes:
- A bridge network
app-networkallows services to communicate securely. - A named volume
db-dataensures MySQL data persists even if the container stops.
- A bridge network
This setup provides a modular, scalable microservice architecture where each component can be independently deployed and managed.
Conclusion
Choosing between microservices and nano-services is like choosing between a buffet and Γ la carte dining. Microservices give you a structured, well-prepared meal, while nano-services let you pay only for what you eatβbut can be surprisingly expensive if youβre always hungry!
If youβre building a large-scale application with high performance needs, Docker-based microservices are a better choice. If youβre experimenting or need an event-driven architecture, AWS Lambda (nano-services) might be more suitable.
Ultimately, the best architecture depends on your project, your team, and your budget. Choose wiselyβand may your API calls always return 200 OK! ![]()
Happy coding and may your containers always stay up! ![]()