Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion .github/workflows/deploy-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Pull image tag on EC2 and restart
id: deploy
uses: appleboy/ssh-action@v1.2.5
env:
SERVER_IMAGE: ${{ needs.build-and-push.outputs.image }}
Expand All @@ -106,6 +107,10 @@ jobs:
aws ecr get-login-password --region "$AWS_REGION" \
| docker login --username AWS --password-stdin "$ECR_REGISTRY"
cd ~/ameet
# Record the currently-running image BEFORE switching, so a failed
# post-deploy health check can roll back to the last-good release.
# Empty file (|| true) means "no prior container" — a first deploy.
docker inspect --format '{{.Config.Image}}' a-meet-server > .rollback-image 2>/dev/null || true
# Check out the EXACT commit that produced SERVER_IMAGE (detached HEAD)
# rather than moving `main`, so the compose file + env contract used to
# deploy always match the image tag being released — even if a newer
Expand All @@ -120,8 +125,9 @@ jobs:
docker image prune -f

- name: Health check
id: health
run: |
HEALTH_URL="${{ vars.HEALTH_URL || 'https://api.ameet.raja-dev.me/api/health' }}"
HEALTH_URL="${{ vars.HEALTH_URL || 'https://api.ameet.raja-dev.me/api/health/ready' }}"
for i in $(seq 1 12); do
if curl -fsS "$HEALTH_URL" | grep -q '"ok":true'; then
echo "Health check passed (attempt $i)"
Expand All @@ -132,3 +138,59 @@ jobs:
done
echo "Health check failed: API did not return ok:true after 60s"
exit 1

# Automated rollback: if either the deploy step OR the post-deploy health
# check failed, redeploy the image that was running before this release.
# Because every build is an immutable :<git-sha> tag, rollback is just
# re-running the deploy against the prior tag recorded above in
# .rollback-image. The deploy step can fail *after* `docker compose up -d`
# has already stopped/replaced the old container (e.g. the new image starts
# but a later command errors), so a deploy-step failure can leave the box
# without the last-good release — we must roll back there too, not only on
# a health-check failure. The rollback re-checkout uses the SHA recorded in
# .rollback-image; if none exists (first deploy) it exits red for a human.
# This step restores service but the job still ends red, so the existing
# CloudWatch/Telegram alarm path still notifies a human.
- name: Roll back to previous image on failed deploy or health check
if: ${{ failure() && (steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure') }}
uses: appleboy/ssh-action@v1.2.5
env:
ECR_REGISTRY: ${{ needs.build-and-push.outputs.registry }}
AWS_REGION: ${{ vars.AWS_REGION }}
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
command_timeout: 10m
envs: ECR_REGISTRY,AWS_REGION
script: |
set -euo pipefail
cd ~/ameet
PREV_IMAGE="$(cat .rollback-image 2>/dev/null || true)"
if [ -z "$PREV_IMAGE" ]; then
echo "No previous image recorded (first deploy?) — cannot auto-roll back."
exit 1
fi
echo "Rolling back to previous image: $PREV_IMAGE"
aws ecr get-login-password --region "$AWS_REGION" \
| docker login --username AWS --password-stdin "$ECR_REGISTRY"
# The image tag is the prior commit SHA; check that commit out so the
# compose file + env contract match the image being restored.
PREV_SHA="${PREV_IMAGE##*:}"
git fetch --quiet origin main
git checkout --quiet --force --detach "$PREV_SHA"
export SERVER_IMAGE="$PREV_IMAGE"
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
# Confirm the restored container is actually serving before we stop.
for i in $(seq 1 12); do
if curl -fsS http://localhost:5000/api/health/ready | grep -q '"ok":true'; then
echo "Rollback healthy (attempt $i)"
exit 0
fi
echo "Attempt $i: rolled-back API not healthy yet, retrying in 5s..."
sleep 5
done
echo "Rollback deployed but health still failing after 60s — needs a human."
exit 1
6 changes: 3 additions & 3 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Typecheck

# Runs the strict TypeScript type-checker across all workspaces on every PR and
# on pushes to main. While the codebase is still mostly JavaScript this only
# checks the shared contracts and each package's migrated TypeScript (allowJs is
# on, checkJs is off), so it stays green until modules are converted leaf-up.
# on pushes to main. It checks the shared contracts and each package's migrated
# TypeScript (allowJs is on, checkJs is off), so it stays green while any
# remaining JavaScript modules are converted leaf-up.
on:
push:
branches: [main]
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ migration) because it only breaks when *behavior* breaks.

**Prior art — match this style:**

- `client/src/components/VideoTile.test.jsx` — opens the volume menu and asserts on the
- `client/src/components/VideoTile.test.tsx` — opens the volume menu and asserts on the
rendered result without reaching into or mutating call state.
- `server/test/sfu-handlers-authz.test.js` — the capture-and-invoke pattern: register the
handlers, capture the socket callbacks, invoke them, and assert the *effect* (host action
Expand Down Expand Up @@ -67,6 +67,6 @@ npm run verify
coverage ratchet, and the `Playwright smoke`). A green `verify` means those gates are satisfied. It does **not** run
the path-scoped `Server image smoke` job (a ~15-min Docker build of the production image
that spawns a real mediasoup worker) — CI only runs that when server-image files change
(`server/src/**`, `server/Dockerfile`, `server/.dockerignore`, `server/package*.json`,
`docker-compose.prod.yml`).
(`server/src/**`, `server/Dockerfile`, `.dockerignore`, `server/package.json`, `shared/**`,
`package.json`, `package-lock.json`, `docker-compose.prod.yml`).
The `test:e2e:install` step is a one-time prerequisite — it is *not* part of `npm ci`.
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,23 @@ npm --prefix server install

### 2. Configure environment

Two env files, with distinct jobs:
Three env files, with distinct jobs:

```bash
cp .env.example .env # repo root — local Docker Mongo credentials (read by Compose)
cp server/.env.example server/.env # server app config (read by the Node server)
cp client/.env.example client/.env # client app config (Vite reads VITE_SERVER_URL for Socket.io)
```

`docker compose` reads the **repo-root `.env`** for the Mongo container credentials, so those
live there — not in `server/.env`. Keep the two in sync: the username/password in the root
`.env` must match the ones embedded in the server's `MONGO_URI` (both default to
`admin` / `change-me`).

The **`client/.env`** supplies `VITE_SERVER_URL` — the origin the browser opens the Socket.io
connection to. There is no built-in fallback, so on a fresh clone signaling silently fails to
connect until this file exists; the default points at the local server (`http://localhost:5000`).

Repo-root `.env` (local Docker Mongo only — unused in production):

```env
Expand Down Expand Up @@ -235,7 +240,7 @@ A-Meet/
│ └── src/
│ ├── routes/ # auth, meetings, rooms
│ ├── socket/ # room events, SFU signalling
│ ├── models/ # User, Meeting
│ ├── models/ # User, Room
│ └── middleware/ # JWT cookie auth
├── docker-compose.yml # LOCAL DEV: MongoDB + observability stack
├── docker-compose.prod.yml # PRODUCTION: server only (DB is Atlas via MONGO_URI)
Expand Down Expand Up @@ -297,7 +302,7 @@ git clone ... && cd A-Meet # the node keeps a checkout for config (compose fil

# Runtime secrets are loaded from SSM by the container entrypoint. No production
# server/.env is required or copied into the image.
# Open UDP ports 10000–59999 in the security group (mediasoup RTP range), plus 5000 (API).
# Open UDP ports 40000–40100 in the security group (mediasoup RTP range), plus 5000 (API).

# Pull the published image tag and start (this is what CI automates on each deploy):
export SERVER_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/a-meet-server:<git-sha>
Expand Down Expand Up @@ -328,7 +333,7 @@ cleanly, so merges to `main` stay green. To enable it:
|---|---|
| `AWS_REGION` | ECR / deploy region, e.g. `ap-south-1` |
| `ECR_REPOSITORY` | ECR repo name, e.g. `a-meet-server` (must exist) |
| `HEALTH_URL` | optional; defaults to `https://api.ameet.raja-dev.me/api/health` |
| `HEALTH_URL` | optional; defaults to `https://api.ameet.raja-dev.me/api/health/ready` |

**Repository secrets:**

Expand Down Expand Up @@ -471,9 +476,9 @@ the instance isn't `ebs`-backed. That makes it safe to use as an automated post-

**Operator validation after a recovery event:**
1. `deploy/aws-recovery.sh verify` — EIP still associated to the instance; alarm back to `OK`; root device `ebs`.
2. **API health:** `curl -fsS https://api.<domain>/api/health` returns `{"ok":true}` (the deploy health check uses the same endpoint).
2. **API health:** `curl -fsS https://api.<domain>/api/health/ready` returns `{"ok":true}` (the deploy health check uses the same readiness endpoint).
3. **Container up:** on the box, `docker compose -f docker-compose.prod.yml ps` shows the server running, and `logs -f` shows mediasoup workers started.
4. **Media connectivity:** join a meeting from two devices and confirm audio/video flows — i.e. `MEDIASOUP_ANNOUNCED_IP` still equals the EIP and the security-group UDP RTC range (10000–59999) is open.
4. **Media connectivity:** join a meeting from two devices and confirm audio/video flows — i.e. `MEDIASOUP_ANNOUNCED_IP` still equals the EIP and the security-group UDP RTC range (40000–40100) is open.

For HTTPS (required for camera/mic on non-localhost), put Nginx in front with a Let's Encrypt cert and proxy to `localhost:5000`.

Expand Down
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
Expand Down
18 changes: 18 additions & 0 deletions deploy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,24 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;

# Security headers at the TLS edge. Applied here (with `always`) so they also
# cover nginx-generated responses — the port-80 301 redirect and the catch-all
# 404 — which never reach the Express/helmet upstream. helmet already emits
# both on proxied /api and /socket.io responses, so those locations strip the
# upstream copies (proxy_hide_header below) to avoid duplicate headers.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;

location /api/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
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;
# Drop helmet's duplicates; the edge add_header above provides the single copy.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header X-Content-Type-Options;
}

location /socket.io/ {
Expand All @@ -41,6 +52,13 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
# WebSocket connections are long-lived and mostly idle between events;
# the default 60s proxy_read_timeout would drop them. Hold them open well
# past Socket.io's ping interval so the upgrade isn't severed mid-call.
proxy_read_timeout 3600s;
# Same duplicate-stripping as /api/ (helmet runs on every upstream response).
proxy_hide_header Strict-Transport-Security;
proxy_hide_header X-Content-Type-Options;
}

# Catch-all: any path not matched above returns 404 (no static files on EC2)
Expand Down
24 changes: 24 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ services:
# Auto-restart on crash/reboot; the server drains gracefully on the SIGTERM
# Docker sends, so restarts don't drop in-flight media abruptly.
restart: unless-stopped
# Resource ceilings so a runaway process (leaked producers, a busy call)
# can't starve nginx/system on the box. Honored by `docker compose up`
# (non-swarm). Sized for a small single node (~2 vCPU / 2-4 GB); raise on a
# larger instance. Reservation keeps a floor for steady-state mediasoup work.
mem_limit: 1536m
mem_reservation: 512m
cpus: 1.5
# Readiness probe against the same endpoint the deploy workflow polls
# (/api/health/ready -> 200 {"ok":true} only when Mongo is connected AND the
# mediasoup workers are alive; 503 otherwise). This is dependency-aware, so a
# container that is up but can't actually serve calls is reported unhealthy.
# The runtime image is node:22-slim with no curl/wget, so use node's global
# fetch; host networking means the server is reachable on localhost:5000.
# start_period covers mediasoup worker spin-up.
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://localhost:5000/api/health/ready').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
environment:
NODE_ENV: production
AWS_REGION: ${AWS_REGION:?set AWS_REGION}
Expand Down
3 changes: 3 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"version": "0.0.0",
"description": "A-Meet end-to-end test harness (Playwright)",
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"test": "playwright test",
"test:sfu": "playwright test --config playwright.sfu.config.js",
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"description": "A-Meet — a Google Meet clone (MERN + Socket.io + mediasoup SFU)",
"engines": {
"node": ">=22.13.0"
},
"workspaces": [
"client",
"server",
Expand Down
8 changes: 8 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
PORT=5000
NODE_ENV=development

# Pino log level (trace|debug|info|warn|error|fatal). Overrides everywhere when
# set; otherwise defaults to `debug` in development and `info` in production.
LOG_LEVEL=debug

# AWS region. Local dev leaves this unset; docker-compose.prod.yml sets it in
# production, where it selects the CloudWatch logs and SSM parameter region.
# AWS_REGION=eu-north-1

# Production only: docker-compose.prod.yml sets this to an SSM path and the
# image entrypoint loads/decrypts its parameters before the app starts.
# Local development leaves it unset and continues to use this .env file.
Expand Down
3 changes: 3 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "A-Meet server — Express + Mongoose + Passport + Socket.io",
"main": "index.js",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
Expand Down
3 changes: 3 additions & 0 deletions shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"exports": {
".": "./src/index.ts"
},
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json"
Expand Down
Loading