How I Built a Full-Stack Roulette Game with Claude AI and Deployed It to AWS β While Learning Everything Along the Way
Architecting Containerized Full-Stack Applications: A Practical Guide to Docker, Django, and Next.js on AWS Lightsail
Current Situation Analysis
The gap between local prototyping and production-ready deployment remains one of the most persistent friction points in modern web development. Tutorials routinely stop at npm run dev or python manage.py runserver, leaving developers unprepared for the infrastructure realities that production environments demand. Container orchestration, reverse proxy configuration, SSL termination, DNS routing, and resource-constrained deployment are rarely covered in depth, yet they form the backbone of any scalable application.
This problem is frequently misunderstood because developers treat infrastructure as an afterthought rather than a first-class architectural concern. When moving from a single-framework prototype to a multi-service stack, the complexity shifts from application logic to system design. Docker Compose simplifies local orchestration, but it introduces new failure modes: ephemeral filesystems, volume permission mismatches, and resource exhaustion during build phases.
Data from real-world deployments consistently highlights a critical bottleneck: Node.js build processes routinely consume 1.5GB to 2GB of RAM. Budget VPS instances, such as the entry-tier AWS Lightsail plans (512MB RAM), will silently hang or terminate during docker compose build without explicit swap configuration or external build strategies. Additionally, framework-specific tooling like Django migrations assumes persistent filesystem state, which directly conflicts with containerized ephemeral storage. These mismatches between development assumptions and container realities cause prolonged debugging cycles, especially when developers lack systematic infrastructure documentation.
Modern AI coding assistants have shifted the development workflow from manual implementation to iterative debugging. However, they are frequently mischaracterized as autonomous code generators. In practice, they function as context-aware debugging partners that accelerate learning when paired with deliberate architectural decision-making. The developer retains control over system design, while the assistant handles implementation details, error diagnosis, and configuration scaffolding. This paradigm requires a fundamental shift in how developers approach full-stack projects: from writing code to orchestrating systems.
WOW Moment: Key Findings
The most impactful realization when transitioning to containerized full-stack deployments is that build strategy dictates infrastructure cost. How you compile and distribute your application artifacts determines whether a budget VPS can handle production traffic or collapses under its own build process.
| Deployment Strategy | Runtime RAM Requirement | Build Time | Infrastructure Complexity | Failure Mode |
|---|---|---|---|---|
| On-Server Build | 1.5GB+ | 3β5 minutes | Low (single workflow) | OOM kills, silent hangs |
| Pre-Built Registry | <256MB | <30 seconds | Medium (CI/CD setup) | Registry auth, image drift |
| Local Build + Push | 1.5GB+ (local machine) | 1β2 minutes | Low (manual transfer) | Network latency, version mismatch |
This comparison reveals a critical trade-off: on-server builds minimize workflow complexity but demand disproportionate compute resources. Pre-built container registries decouple build and runtime environments, enabling deployment on constrained VPS tiers while introducing registry management overhead. The finding matters because it shifts the optimization target from code efficiency to infrastructure economics. By externalizing the build phase, developers can run production-grade stacks on entry-level cloud instances without sacrificing reliability.
Core Solution
Building a production-ready full-stack application requires aligning framework strengths with infrastructure constraints. The architecture below separates concerns across four distinct services: a reverse proxy for traffic routing and SSL termination, a frontend framework for SEO and client-side interactivity, a backend API for business logic and authentication, and a relational database for persistent state.
Architecture Decisions and Rationale
- Nginx as Edge Proxy: Nginx handles SSL termination, static asset caching, and request buffering. It shields backend services from direct internet exposure and normalizes headers for downstream applications.
- Next.js for Frontend: Server-side rendering and static generation improve SEO performance and reduce client-side JavaScript payload. The framework's built-in API routes allow lightweight backend logic without overcomplicating the Django layer.
- Django REST Framework for Backend: Django provides mature authentication, an admin interface, and a robust ORM. DRF structures API endpoints consistently, while Django's migration system manages schema evolution predictably.
- PostgreSQL for Persistence: ACID compliance, JSONB support, and mature connection pooling make PostgreSQL ideal for game state, user profiles, and transactional data.
- Docker Compose for Orchestration: Local parity with production, declarative service definitions, and built-in networking simplify multi-container management without Kubernetes overhead.
Implementation: Service Orchestration
The following docker-compose.yml defines the production stack. Service names, environment variables, and volume mounts are structured for clarity and operational safety.
version: '3.8'
services:
proxy-gateway:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
frontend-app:
condition: service_healthy
backend-api:
condition: service_healthy
restart: unless-stopped
frontend-app:
build:
context: ./frontend
dockerfile: Dockerfile.prod
environment:
- NEXT_PUBLIC_API_URL=http://backend-api:8000/api/v1
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
backend-api:
build:
context: ./backend
dockerfile: Dockerfile.prod
command: >
sh -c "python manage.py migrate --noinput &&
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3"
environment:
- DATABASE_URL=postgres://app_user:$(DB_PASSWORD)@db-primary:5432/game_db
- DJANGO_SECRET_KEY=$(DJANGO_SECRET)
- ALLOWED_HOSTS=proxy-gateway,localhost,127.0.0.1
volumes:
- static_assets:/app/staticfiles
- media_uploads:/app/media
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health/"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
db-primary:
condition: service_healthy
restart: unless-stopped
db-primary:
image: postgres:16-alpine
environment:
- POSTGRES_DB=game_db
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=$(DB_PASSWORD)
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d game_db"]
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pg_data:
static_assets:
media_uploads:
Why this structure works:
- Health checks prevent cascading failures by ensuring dependencies are ready before routing traffic.
- Named volumes persist database state and static assets across container rebuilds.
- Environment variable injection via
.envfiles keeps secrets out of version control. - Gunicorn replaces Django's development server, providing production-grade WSGI handling.
Backend Implementation: Django Models and Views
Django models define the schema, while DRF views handle request routing and serialization. The following example demonstrates a player profile and game session tracker.
# backend/apps/core/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
class PlayerProfile(AbstractUser):
balance = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
preferred_variant = models.CharField(max_length=20, choices=[('EU', 'European'), ('US', 'American'), ('TZ', 'Triple Zero')], default='EU')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.username
class GameSession(models.Model):
player = models.ForeignKey(PlayerProfile, on_delete=models.CASCADE, related_name='sessions')
variant = models.CharField(max_length=20)
total_wagered = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
net_profit = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
started_at = models.DateTimeField(auto_now_add=True)
ended_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-started_at']
# backend/apps/core/serializers.py
from rest_framework import serializers
from .models import PlayerProfile, GameSession
class PlayerProfileSerializer(serializers.ModelSerializer):
class Meta:
model = PlayerProfile
fields = ['id', 'username', 'balance', 'preferred_variant', 'created_at']
read_only_fields = ['id', 'created_at']
class GameSessionSerializer(serializers.ModelSerializer):
class Meta:
model = GameSession
fields = ['id', 'variant', 'total_wagered', 'net_profit', 'started_at', 'ended_at']
read_only_fields = ['id', 'started_at']
Why this approach:
AbstractUserextends Django's built-in authentication, enabling session management and permission checks without reinventing security.DecimalFieldprevents floating-point precision errors in financial calculations.- Serializers decouple database representation from API response format, allowing future schema changes without breaking clients.
Frontend Implementation: Next.js Game Board
The frontend consumes the Django API and manages client-side state for interactive gameplay. The following snippet demonstrates a simplified spin engine hook and board component.
// frontend/src/hooks/useSpinEngine.ts
import { useState, useCallback } from 'react';
interface SpinResult {
pocket: number;
color: 'red' | 'black' | 'green';
payout: number;
}
export function useSpinEngine(apiUrl: string) {
const [isSpinning, setIsSpinning] = useState(false);
const [lastResult, setLastResult] = useState<SpinResult | null>(null);
const executeSpin = useCallback(async (betAmount: number, betType: string) => {
setIsSpinning(true);
try {
const response = await fetch(`${apiUrl}/game/spin/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: betAmount, type: betType }),
});
const data = await response.json();
setLastResult(data.result);
return data.result;
} catch (error) {
console.error('Spin execution failed:', error);
throw error;
} finally {
setIsSpinning(false);
}
}, [apiUrl]);
return { isSpinning, lastResult, executeSpin };
}
// frontend/src/components/GameBoard.tsx
import { useSpinEngine } from '@/hooks/useSpinEngine';
export function GameBoard({ apiUrl }: { apiUrl: string }) {
const { isSpinning, lastResult, executeSpin } = useSpinEngine(apiUrl);
const handleBet = async (amount: number, type: string) => {
await executeSpin(amount, type);
};
return (
<div className="game-container">
<div className="wheel-display">
{isSpinning ? <span className="spinner">Spinning...</span> : <span className="result">{lastResult?.pocket ?? 'Ready'}</span>}
</div>
<div className="controls">
<button onClick={() => handleBet(10, 'straight')} disabled={isSpinning}>Bet 10 (Straight)</button>
<button onClick={() => handleBet(5, 'red')} disabled={isSpinning}>Bet 5 (Red)</button>
</div>
{lastResult && (
<div className="payout-info">
Pocket: {lastResult.pocket} | Color: {lastResult.color} | Payout: {lastResult.payout}
</div>
)}
</div>
);
}
Why this structure:
- Custom hooks isolate side effects and API calls, keeping components declarative.
- Server-side fetching can be layered on top for SEO-critical pages, while client-side hooks handle interactive state.
- Type safety prevents runtime mismatches between backend payloads and frontend expectations.
Pitfall Guide
1. Ephemeral Migration Files
Explanation: Django generates migration files during makemigrations. If executed inside a running container, the files exist only in the container's writable layer. Stopping the container destroys them, causing Table does not exist errors on restart.
Fix: Run makemigrations on the host machine, commit the generated Python files to version control, and execute migrate during container startup via entrypoint scripts or Docker Compose commands.
2. RAM Exhaustion During Node Builds
Explanation: Next.js compilation invokes Webpack, TypeScript, and Babel simultaneously. On a 512MB VPS, the OOM killer terminates the process silently, leaving docker compose build hanging indefinitely.
Fix: Add swap space (sudo fallocate -l 1G /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile), use pre-built images from a container registry, or upgrade to a 1GB+ instance. Monitor memory with docker stats during builds.
3. Multi-Stage Build Misconfiguration
Explanation: Copying node_modules or development dependencies into the production image bloats the final container and introduces security vulnerabilities.
Fix: Use explicit multi-stage builds. Compile assets in a node:20-bullseye stage, then copy only .next/standalone, .next/static, and public/ into an alpine or slim runtime image. Verify image size with docker images.
4. Reverse Proxy Header Loss
Explanation: Nginx forwards requests to backend services using localhost, causing Django and Next.js to log internal IPs instead of client addresses. This breaks rate limiting, analytics, and security middleware.
Fix: Configure proxy_set_header X-Real-IP $remote_addr;, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, and proxy_set_header Host $host; in the Nginx server block. Enable USE_X_FORWARDED_HOST = True in Django settings.
5. Hardcoded Secrets in Compose Files
Explanation: Embedding database passwords or API keys directly in docker-compose.yml exposes them in version control and container inspection logs.
Fix: Use .env files referenced via env_file directives. For production, inject secrets through cloud provider secret managers or Docker Swarm secrets. Never commit .env to Git.
6. Missing Health Checks
Explanation: Without health checks, Docker restarts failing containers in tight loops, masking underlying errors and consuming CPU cycles.
Fix: Define healthcheck blocks for every service. Use lightweight commands (curl, pg_isready, wget) with appropriate intervals and retries. Monitor status with docker compose ps.
7. Ignoring DNS and SSL Early
Explanation: Deploying without proper DNS routing or SSL certificates forces reconfiguration later, causing downtime and broken client connections. Fix: Provision DNS records before deployment. Use Let's Encrypt with Certbot or cloud-managed certificates. Configure Nginx to redirect HTTP to HTTPS and enforce HSTS headers.
Production Bundle
Action Checklist
- Define environment variables in
.envand exclude it from version control - Generate Django migrations locally and commit them before first deployment
- Configure Nginx proxy headers and SSL termination before routing traffic
- Add health checks to all Docker Compose services
- Verify RAM availability or configure swap space for build phases
- Test multi-stage Dockerfiles locally and audit final image sizes
- Set up automated backups for PostgreSQL volumes and static assets
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Budget VPS (512MB RAM) | Pre-built container registry | Decouples build RAM from runtime, prevents OOM kills | Slightly higher CI/CD complexity, lower instance cost |
| Rapid prototyping | On-server build | Single workflow, minimal tooling overhead | Requires 1GB+ instance, higher monthly cost |
| High-traffic production | Kubernetes + Helm | Auto-scaling, rolling updates, service mesh | Significant operational overhead, higher infrastructure cost |
| Small team / solo dev | Docker Compose + Lightsail | Simple orchestration, predictable pricing, easy debugging | Manual scaling, single point of failure |
Configuration Template
Nginx Reverse Proxy (nginx/conf.d/default.conf)
upstream frontend {
server frontend-app:3000;
}
upstream backend {
server backend-api:8000;
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 10M;
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Django Settings Snippet (backend/config/settings/production.py)
import os
from .base import *
DEBUG = False
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('DB_NAME', 'game_db'),
'USER': os.getenv('DB_USER', 'app_user'),
'PASSWORD': os.getenv('DB_PASSWORD'),
'HOST': os.getenv('DB_HOST', 'db-primary'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
Quick Start Guide
- Initialize Environment: Create a
.envfile withDB_PASSWORD,DJANGO_SECRET, andALLOWED_HOSTS. Add it to.gitignore. - Generate Migrations: Run
docker compose run backend-api python manage.py makemigrationsand commit the output. - Build and Launch: Execute
docker compose up -d --build. Monitor logs withdocker compose logs -f. - Verify Health: Check
docker compose psforhealthystatus. Test endpoints viacurl http://localhost/api/v1/health/. - Configure DNS/SSL: Point your domain to the Lightsail IP. Run Certbot or upload cloud certificates to
./certs/. Restart Nginx withdocker compose restart proxy-gateway.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
