Dockerizing Microservices: A Practical Guide to Scalable Architecture

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! :rocket:

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 command specifies which module to run with Uvicorn.
    • Services expose different ports (8003, 8004, 8005) for communication.
    • They depend on the db service to be healthy before starting.
    • Environment variables are loaded from .env .
    • Connected to app-network for 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-network allows services to communicate securely.
    • A named volume db-data ensures MySQL data persists even if the container stops.

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! :rocket:

Happy coding and may your containers always stay up! :rocket:

6 Likes