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).
- Application Load Balancer URL: http://docker-aws-practice-alb-1169726276.ap-northeast-1.elb.amazonaws.com/
- Region:
ap-northeast-1(Tokyo, inferred from ALB DNS suffix) - Public listener: Port 80 HTTP
- Container listener: Port 3000
This project moves beyond local development to demonstrate how modern applications are architected for high availability and security in the cloud.
- Multi-Stage Builds: Engineered a layered
Dockerfileto 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 buildxwith the--platform linux/amd64flag to ensure the image architecture perfectly matches the AWS cloud hardware, eliminating local-to-cloud architecture mismatch errors. - Cache and Context Optimization: Implemented strict
.dockerignorerules (node_modules,npm-debug.log,.env,.git) to prevent localnode_modulesand environment variables from polluting the container registry.
- 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.
- 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.
# ---------- 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"]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.
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
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"]
Key optimizations:
package*.jsonis copied before source in both stages, so Docker layer cache reusesnpm installunless dependencies change. Source:Dockerfile:8-9,22-23..dockerignoreexcludesnode_modules,.env,.git, preventing host contamination and keeping build context small.- Final image contains:
node_modulesfor backend only,server.js, and compiledpublic/assets. It does not contain frontendnode_modules, Vite dev server, or TypeScript sources.
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.htmlmounts<div id="root">and loads/src/main.jsx.src/main.jsx:6-10creates React root inStrictModeand rendersApp.src/App.jsxowns 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
aside25 percent width: active users list derived from awareness states. - Right
section75 percent width: Monaco<Editor height="100%" defaultLanguage="javascript" theme="vs-dark">.
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:
express.static('public')serves Vitedistoutput.GET /returnspublic/index.html. JS/CSS/assets served from same origin. Source:backend/server.js:8.createServer(app)wraps Express so Socket.IO can share port 3000. Source:backend/server.js:10.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.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.GET /healthreturns{ message: "ok", success: true }with 200 for ALB target group checks. Source:backend/server.js:22-27.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.
| 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 |
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
Failure points:
- Wrong
--platformproducesexec format erroron Fargate (arm64 image on amd64 host or reverse). - Missing Execution Role ECR permissions produces
CannotPullContainerError. /healthreturning non-200 or slow start produces ALB5xxand task replacement loop.
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
All assets share the same ALB origin, so no preflight CORS occurs for HTTP. WebSocket upgrade later reuses the same host.
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]
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
beforeunloadclearingusertonullremoves the user from others lists. Source:frontend/src/App.jsx:66-68.
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
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-monacobinds 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.
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"]
| 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.
- 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 referencingalb-sgas the only allowed source. - 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.
- 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. - 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
monacoroom and edit. - No request logging, access logs, or Container Insights described. Enable ALB access logs to S3 and ECS CloudWatch Logs.
GET /healthis unauthenticated and on the same port. That is standard for ALB, but ensure it does not leak version details.
- Socket.IO CORS is
| 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 |
- Vertical: Increase Fargate CPU/memory in task definition. No code change needed. Useful for larger Monaco documents or many concurrent sockets.
- Horizontal: Increasing
DesiredCountabove 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 fornpmimage pull plusnode server.jsboot. - Observability minimum: CloudWatch log group for
server.jsstdout (Server is running on port 3000), ALBHealthyHostCount/UnhealthyHostCount/TargetResponseTimealarms, ECSCPUUtilization/MemoryUtilizationalarms.