Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cloud-Native Web Application | AWS and Docker Architecture Showcase

Docker AWS NodeJS React

A production-grade, containerized full-stack web application deployed entirely on Amazon Web Services (AWS). This repository is primarily a showcase of advanced DevOps engineering, emphasizing scalable cloud infrastructure, strict network security, and highly optimized multi-stage container builds.

Application type: real-time collaborative code editor (Monaco + Yjs CRDT + Socket.IO).

Live Production Endpoint


Cloud Infrastructure and DevOps Engineering

This project moves beyond local development to demonstrate how modern applications are architected for high availability and security in the cloud.

1. Advanced Containerization (Docker)

  • Multi-Stage Builds: Engineered a layered Dockerfile to separate the build environment from the runtime environment. The frontend is compiled in an isolated Node container, and only the finalized static assets (/dist) are injected into the backend serving container. This drastically reduces the final image size and attack surface.
  • Cross-Platform Compilation: Utilized docker buildx with the --platform linux/amd64 flag to ensure the image architecture perfectly matches the AWS cloud hardware, eliminating local-to-cloud architecture mismatch errors.
  • Cache and Context Optimization: Implemented strict .dockerignore rules (node_modules, npm-debug.log, .env, .git) to prevent local node_modules and environment variables from polluting the container registry.

2. Serverless Cloud Architecture (AWS ECS Fargate)

  • Elastic Container Registry (ECR): Secured private registry for storing and versioning the compiled production images.
  • Elastic Container Service (ECS) with Fargate: Deployed the container using AWS Fargate for a fully serverless execution layer, removing the operational overhead of provisioning and managing underlying EC2 instances.
  • Granular IAM Security: Configured strict Identity and Access Management (IAM) policies. Separated Task Execution Roles (allowing ECS to pull from ECR) from Task Roles (application-level permissions) to adhere to the Principle of Least Privilege.

3. Secure Cloud Networking (AWS VPC and ALB)

  • Virtual Private Cloud (VPC): Provisioned a custom VPC with dedicated public subnets attached to an Internet Gateway for isolated execution.
  • Application Load Balancer (ALB): Placed an internet-facing ALB in front of the ECS cluster. The ALB safely intercepts external Port 80 traffic and forwards it to the container's internal Port 3000.
  • Stateful Firewalls (Security Groups): Configured strict AWS Security Groups to drop unauthorized inbound traffic, ensuring the container instances can only be accessed via the Load Balancer.

Multi-Stage Dockerfile Blueprint

# ---------- FRONTEND BUILD ----------
FROM node:20-alpine AS frontend-builder
WORKDIR /app

COPY frontend/package*.json ./
RUN npm install

COPY frontend .
RUN npm run build

# ---------- BACKEND BUILD ----------
FROM node:20-alpine


WORKDIR /app

COPY backend/package*.json ./
RUN npm install


COPY backend .
COPY --from=frontend-builder /app/dist ./public
EXPOSE 3000

CMD ["node", "server.js"]

System Architecture

High-Level Overview

The system is a single-container full-stack deployment. The React frontend is built to static files at image build time and served by the same Node.js Express process that hosts the real-time collaboration backend. There is no separate frontend host, no external database, and no cache layer. All persistent and ephemeral state lives in memory in the Fargate task.

In-memory state implications:

  • Document content (Yjs Y.Doc) lives per task process, synchronized only among clients connected to that task via Socket.IO.
  • A task restart, redeploy, or scale-to-multiple-tasks loses or splits document state.
  • Horizontal scaling without sticky sessions or a shared Yjs persistence backend would partition collaboration rooms.

Full Architecture Diagram

graph TB
    subgraph Client["Client Layer - Browser"]
        Browser["Web Browser<br/>React 19 + Vite SPA<br/>Monaco Editor + Yjs + y-socket.io"]
    end

    subgraph AWS_Edge["AWS Edge / Networking"]
        IGW["Internet Gateway"]
        ALB["Application Load Balancer<br/>internet-facing<br/>Listener: 80 HTTP<br/>Target: 3000 HTTP"]
        SG_ALB["SG: alb-sg<br/>Inbound: 80 from 0.0.0.0/0<br/>Outbound: to ECS on 3000"]
        SG_ECS["SG: ecs-tasks-sg<br/>Inbound: 3000 from alb-sg only<br/>Outbound: ECR + Internet via IGW"]
    end

    subgraph VPC["VPC - Custom - Public Subnets in 2+ AZs"]
        direction TB
        ALB
        subgraph ECSCluster["ECS Cluster - Fargate Launch Type"]
            ECSService["ECS Service<br/>DesiredCount: 1+<br/>HealthCheck: GET /health"]
            subgraph Task["Fargate Task - Single Container - linux/amd64"]
                Container["Container: node:20-alpine<br/>CMD: node server.js<br/>Port: 3000"]
                subgraph AppProc["Node Process - server.js"]
                    ExpressStatic["Express static middleware<br/>public/ - Vite dist<br/>GET / index.html + assets"]
                    HealthAPI["GET /health<br/>200 - message:ok success:true"]
                    SocketIOServer["Socket.IO Server<br/>cors origin:*<br/>y-socket.io YSocketIO"]
                    YDocMemory["In-memory Yjs Rooms<br/>room: monaco"]
                end
            end
        end
    end

    subgraph RegistryIAM["Registry and Identity"]
        ECR["ECR Private Repository<br/>versioned production images"]
        ExecRole["IAM Task Execution Role<br/>ecr:GetAuthorizationToken<br/>ecr:BatchGetImage<br/>logs:CreateLogStream"]
        TaskRole["IAM Task Role<br/>minimal app permissions<br/>no AWS API use today"]
    end

    Browser -- "HTTP :80<br/>GET / , /assets/*<br/>WebSocket upgrade<br/>Socket.IO" --> ALB
    ALB -- "Forward :3000<br/>Target Group<br/>/health checks" --> Container
    ECSService -- "pull image" --> ECR
    ECSService -- "assume" --> ExecRole
    Container -- "assume" --> TaskRole
    IGW --- VPC
    SG_ALB --- ALB
    SG_ECS --- Task

    style ALB fill:#FF9900,color:#000
    style ECR fill:#FF9900,color:#000
    style Container fill:#6DA55F,color:#000
Loading

Container Build Flow

The Dockerfile has two stages. Only the second stage ships to ECR and Fargate.

graph LR
    subgraph Stage1["Stage 1: frontend-builder - node:20-alpine"]
        F1["COPY frontend/package*.json"] --> F2["RUN npm install<br/>React, Monaco, Yjs, Tailwind"]
        F2 --> F3["COPY frontend source"]
        F3 --> F4["RUN npm run build<br/>vite build -> /app/dist<br/>index.html + assets/*.js/css"]
    end

    subgraph Stage2["Stage 2: runtime - node:20-alpine"]
        B1["COPY backend/package*.json"] --> B2["RUN npm install<br/>express, socket.io, y-socket.io"]
        B2 --> B3["COPY backend source<br/>server.js"]
        B3 --> B4["COPY --from=frontend-builder<br/>/app/dist -> ./public"]
        B4 --> B5["EXPOSE 3000<br/>CMD node server.js"]
    end

    F4 -. "dist only<br/>no node_modules<br/>no source maps leak" .-> B4
    B5 --> ECRPush["docker buildx --platform linux/amd64<br/>docker push to ECR"]
Loading

Key optimizations:

  1. package*.json is copied before source in both stages, so Docker layer cache reuses npm install unless dependencies change. Source: Dockerfile:8-9,22-23.
  2. .dockerignore excludes node_modules, .env, .git, preventing host contamination and keeping build context small.
  3. Final image contains: node_modules for backend only, server.js, and compiled public/ assets. It does not contain frontend node_modules, Vite dev server, or TypeScript sources.

Component Deep Dive

1. Frontend - frontend/

Stack: React 19, Vite 7, Tailwind CSS 4 via @tailwindcss/vite, Monaco Editor via @monaco-editor/react, CRDT via yjs, binding via y-monaco, transport via y-socket.io.

Entry chain:

  • index.html mounts <div id="root"> and loads /src/main.jsx.
  • src/main.jsx:6-10 creates React root in StrictMode and renders App.
  • src/App.jsx owns all collaboration logic. No router, no state manager, no API client beyond Socket.IO provider.

Core frontend state:

Concern Implementation Location
Shared document new Y.Doc() memoized once per page load, ydoc.getText("monaco") shared text type frontend/src/App.jsx:16-17
Editor binding MonacoBinding(yText, editor.getModel(), new Set([editor])) on mount frontend/src/App.jsx:21-28
Identity gate username from ?username query param, otherwise join form blocks editor frontend/src/App.jsx:11-13,33-40,82-102
Room connection new SocketIOProvider("/", "monaco", ydoc, { autoConnect: true }) frontend/src/App.jsx:48-50
Presence provider.awareness.setLocalStateField("user", { username }), subscribe to change events, derive users[] frontend/src/App.jsx:52-64
Cleanup provider.disconnect() and beforeunload clears awareness state frontend/src/App.jsx:66-76

Room name is hardcoded to "monaco" and namespace/path is "/" (same origin). Because backend serves frontend from the same origin and port, no separate CORS host configuration is needed in production.

UI layout (frontend/src/App.jsx:104-133):

  • Left aside 25 percent width: active users list derived from awareness states.
  • Right section 75 percent width: Monaco <Editor height="100%" defaultLanguage="javascript" theme="vs-dark">.

2. Backend - backend/

Stack: Node 20 ESM ("type": "module"), Express 5, http native server, Socket.IO 4, y-socket.io server.

File: backend/server.js (31 lines). Responsibilities in order:

  1. express.static('public') serves Vite dist output. GET / returns public/index.html. JS/CSS/assets served from same origin. Source: backend/server.js:8.
  2. createServer(app) wraps Express so Socket.IO can share port 3000. Source: backend/server.js:10.
  3. new Server(httpServer, { cors: { origin: "*", methods: ["GET","POST"] } }) allows any origin to open WebSocket polling or upgrade. Needed for local Vite dev (localhost:5173) but permissive in production. Source: backend/server.js:12-17.
  4. new YSocketIO(io).initialize() adds Yjs document sync handlers on top of Socket.IO. It manages rooms, joins, step1/step2 sync, and awareness propagation without custom message code. Source: backend/server.js:19-20.
  5. GET /health returns { message: "ok", success: true } with 200 for ALB target group checks. Source: backend/server.js:22-27.
  6. httpServer.listen(3000) is the only listening socket. ALB forwards to this port. Source: backend/server.js:29-31.

There is no authentication, no authorization, no rate limiting, no persistence, no logging framework, and no environment variable configuration. Port is hardcoded.

3. Infrastructure Mapping

AWS Resource Purpose Configuration in this repo
VPC + public subnets + IGW Isolated network, direct internet ingress to ALB, egress to ECR Custom VPC, 2+ AZs recommended
ALB Single public entrypoint, health checking, target routing Listener 80, forward to target group port 3000
ALB Target Group Health-based routing to Fargate tasks Path GET /health, expect HTTP 200
ECS Cluster + Service (Fargate) Serverless container orchestration Platform linux/amd64, container port 3000
ECR Private image registry docker buildx push, ECS pull via execution role
Security Group alb-sg Edge firewall In 80 from internet, out 3000 to tasks
Security Group ecs-tasks-sg Workload firewall In 3000 only from alb-sg
IAM Execution Role Let ECS agent pull image and write logs ECR + CloudWatch Logs read/pull
IAM Task Role App identity Minimal, unused by current code

End-to-End Flows

Flow 1: Build, Push, Deploy

sequenceDiagram
    autonumber
    participant Dev as Developer Machine
    participant Docker as Docker Buildx
    participant ECR as AWS ECR
    participant ECS as AWS ECS + Fargate
    participant ALB as ALB Target Group

    Dev->>Docker: docker buildx build --platform linux/amd64 -t app:tag .
    Note over Docker: Stage 1 builds frontend dist<br/>Stage 2 installs backend deps<br/>and copies dist to ./public
    Docker->>ECR: docker push app:tag
    Dev->>ECS: Update service task definition to app:tag
    ECS->>ECR: Assume Execution Role<br/>BatchGetImage + GetDownloadUrlForLayer
    ECS->>ECS: Start Fargate task<br/>CMD node server.js<br/>Expose 3000
    ECS->>ALB: Register task IP in target group
    ALB->>ECS: GET /health every N seconds
    ECS-->>ALB: 200 message:ok success:true
    ALB->>ALB: Mark target healthy<br/>Begin routing port 80 to 3000
Loading

Failure points:

  • Wrong --platform produces exec format error on Fargate (arm64 image on amd64 host or reverse).
  • Missing Execution Role ECR permissions produces CannotPullContainerError.
  • /health returning non-200 or slow start produces ALB 5xx and task replacement loop.

Flow 2: Initial Page Load (HTTP)

sequenceDiagram
    autonumber
    participant B as Browser
    participant A as ALB :80
    participant E as Express in Fargate :3000

    B->>A: GET / HTTP/1.1 Host: alb-dns
    A->>E: Forward to target IP:3000 GET /
    E-->>B: 200 public/index.html via express.static
    B->>A: GET /assets/index-*.js, *.css
    A->>E: Forward static asset requests
    E-->>B: 200 JS bundle + CSS + favicon
    Note over B: React mounts<br/>Reads ?username<br/>Shows Join form if empty
Loading

All assets share the same ALB origin, so no preflight CORS occurs for HTTP. WebSocket upgrade later reuses the same host.

Flow 3: Join and Presence (Awareness Channel)

sequenceDiagram
    autonumber
    participant U1 as User A Browser
    participant U2 as User B Browser
    participant S as YSocketIO on Socket.IO

    U1->>U1: Submit username form<br/>pushState ?username=A
    U1->>S: Socket.IO connect + join room monaco
    S-->>U1: Sync Step1/Step2 Yjs state vector
    U1->>S: awareness setLocalStateField user:{username:A}
    S->>U2: Broadcast awareness update {A joined}
    U2->>U2: awareness on change<br/>setUsers([...states with user.username])
    U2->>S: awareness setLocalStateField user:{username:B}
    S->>U1: Broadcast awareness update {B joined}
    U1->>U1: Render users list [A, B]
Loading

Implementation notes:

  • Provider path is "/" and room is "monaco" for every user, so all users share one global document. There is no per-document URL or room isolation. Source: frontend/src/App.jsx:48.
  • Awareness states are ephemeral. Disconnect or beforeunload clearing user to null removes the user from others lists. Source: frontend/src/App.jsx:66-68.

Flow 4: Collaborative Edit (CRDT Data Channel)

sequenceDiagram
    autonumber
    participant M1 as Monaco A + Y.Text
    participant P1 as Provider A
    participant S as YSocketIO Server
    participant P2 as Provider B
    participant M2 as Monaco B + Y.Text

    M1->>M1: Local keystroke<br/>MonacoBinding writes to yText
    M1->>P1: Yjs update (insert/delete + clock)
    P1->>S: Socket.IO message: yjs update room=monaco
    S->>S: Apply to in-memory Y.Doc<br/>Append to room history
    S->>P2: Relay update to other sockets in room
    P2->>M2: Apply Yjs update to yText
    M2->>M2: MonacoBinding renders remote edit<br/>No cursor fight, CRDT merge
    Note over M1,M2: Order-independent merge<br/>Last-writer does not win<br/>Both converge to same text
Loading

Why Yjs matters here:

  • Plain Socket.IO text broadcast would overwrite concurrent edits. Yjs encodes edits as CRDT operations on shared type Y.Text("monaco"), so concurrent inserts at the same position merge deterministically.
  • y-monaco binds that shared text type directly to the Monaco model, avoiding manual diff/patch code.
  • The server in this repo is a sync relay only. It does not validate edits, persist to disk, or resolve conflicts beyond CRDT relay.

Flow 5: Health Check and Failure Detection

graph LR
    ALB_HC["ALB Target Group<br/>GET /health:3000"] -->|200 ok| Healthy["Healthy<br/>Keep routing"]
    ALB_HC -->|timeout / 4xx / 5xx<br/>N consecutive fails| Unhealthy["Unhealthy<br/>Deregister task"]
    Unhealthy --> NewTask["ECS launches replacement task<br/>Fresh empty Y.Doc"]
    NewTask --> Note["Note: all connected clients<br/>reconnect and resync<br/>unsaved shared state resets"]
Loading

Data and State Summary

State Where it lives Lifetime Shared across tasks
Vite static files public/ inside image Image version lifetime Yes, identical per task from same image
Y.Doc document text Fargate task RAM via y-socket.io Until task stops/restarts No, per-task only
Awareness (online users) Socket.IO connections + RAM Until disconnect No, per-task only
Health status ALB target state Rolling check window N/A
Image versions ECR tags Until lifecycle expiry Yes

No database, no S3, no ElastiCache, no EFS is used. Adding any of those plus y-socket.io persistence hooks would be required before multi-task scaling is safe.


Networking and Security Details

  1. Ingress path: 0.0.0.0/0:80 -> ALB -> ecs-tasks-sg:3000. Direct access to tasks from the internet is denied by security group referencing alb-sg as the only allowed source.
  2. Egress path: Tasks need outbound HTTPS to ECR (image pull at launch) and response traffic back through ALB. Public subnets with IGW satisfy this without NAT.
  3. IAM separation: Execution Role is used by the ECS agent. Task Role is used by node server.js. Keeping them distinct limits blast radius if app code is compromised.
  4. Current gaps to address before production hardening:
    • Socket.IO CORS is origin: "*" (backend/server.js:13-16). Restrict to ALB DNS or custom domain.
    • ALB listener is HTTP only. Add ACM certificate and HTTPS 443 listener with redirect 80 to 443.
    • No auth on collaboration room. Anyone with the ALB URL can join monaco room and edit.
    • No request logging, access logs, or Container Insights described. Enable ALB access logs to S3 and ECS CloudWatch Logs.
    • GET /health is unauthenticated and on the same port. That is standard for ALB, but ensure it does not leak version details.

Local Development vs Production

Concern Local Production (AWS)
Frontend serve vite dev on localhost:5173 with HMR vite build output served by Express public/ on :3000 behind ALB :80
Backend node backend/server.js on :3000 Same command inside Fargate container
Socket endpoint Cross-origin localhost:5173 to localhost:3000, allowed by cors:* Same-origin / through ALB, no CORS needed
Build No Docker needed buildx --platform linux/amd64 required for Fargate compat

Scaling and Operational Notes

  • Vertical: Increase Fargate CPU/memory in task definition. No code change needed. Useful for larger Monaco documents or many concurrent sockets.
  • Horizontal: Increasing DesiredCount above 1 without affinity splits rooms. Fix by enabling ALB stickiness as a short-term patch, or by adding a shared pub/sub (Redis) plus Yjs persistence as the correct fix.
  • Zero-downtime deploys: Use ECS rolling update with minimumHealthyPercent: 100, maximumPercent: 200, and health check grace period long enough for npm image pull plus node server.js boot.
  • Observability minimum: CloudWatch log group for server.js stdout (Server is running on port 3000), ALB HealthyHostCount/UnhealthyHostCount/TargetResponseTime alarms, ECS CPUUtilization/MemoryUtilization alarms.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages