Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ jobs:
docker compose -f docker-compose.prod.yml ps

# Fail the deploy if any expected service is not running.
for svc in frontend backend ws-server genai db redis; do
for svc in frontend backend ws-server genai; do
state=$(docker compose -f docker-compose.prod.yml ps \
--format '{{.State}}' "$svc" || true)
echo " $svc: ${state:-missing}"
Expand Down
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,6 @@ jobs:
docker compose -f docker-compose.yml config --quiet

- name: Validate prod compose
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rexial
REDIS_PASSWORD: placeholder
run: |
# .env.prod lives on the deploy server and is not in the repo,
# so stand in an empty one just to check the file's structure.
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ node_modules
.env.development.local
.env.test.local
.env.production.local

VPS-DEPLOYMENT.md
# Testing
coverage

Expand Down
79 changes: 43 additions & 36 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@ running, then come back for the *why*.

## Contents

-[The short version](#the-short-version)
-[System overview](#system-overview)
-[Why a monorepo](#why-a-monorepo)
-[The services](#the-services)
-[Data model](#data-model)
-[Flow 1: Creating a quiz](#flow-1-creating-a-quiz)
-[Flow 2: AI question generation](#flow-2-ai-question-generation)
-[Flow 3: Running a live quiz](#flow-3-running-a-live-quiz)
-[Caching and scale](#caching-and-scale)
-[Deployment topology](#deployment-topology)
-[Design decisions](#design-decisions)
-[Known limitations](#known-limitations)
- [The short version](#the-short-version)
- [System overview](#system-overview)
- [Why a monorepo](#why-a-monorepo)
- [The services](#the-services)
- [Data model](#data-model)
- [Flow 1: Creating a quiz](#flow-1-creating-a-quiz)
- [Flow 2: AI question generation](#flow-2-ai-question-generation)
- [Flow 3: Running a live quiz](#flow-3-running-a-live-quiz)
- [Caching and scale](#caching-and-scale)
- [Deployment topology](#deployment-topology)
- [Design decisions](#design-decisions)
- [Known limitations](#known-limitations)

---

Expand Down Expand Up @@ -453,33 +453,40 @@ instance, but nothing in the code assumes that.
## Deployment topology

```
Internet
┌────────────────────────┐
│ nginx-proxy-manager │ :80 :443 (TLS, routing)
│ │ :81 (admin UI)
└──┬─────┬─────┬─────┬───┘
│ │ │ │
┌────────▼┐ ┌──▼───┐ ┌▼─────┐ ┌▼──────┐
│frontend │ │backend│ │ ws │ │ genai │
│ (nginx) │ │ :4000 │ │:8080 │ │ :8000 │
└─────────┘ └───┬───┘ └──┬───┘ └───────┘
│ │
┌─────▼────┐ ┌─▼──────┐
│ postgres │ │ redis │
│ (volume) │ │(volume)│
└──────────┘ └────────┘

all on the private `rexial-network` bridge
Internet
▼ :80 :443
┌───────────────────── VPS ──────────────────────────┐
│ ┌────────────────────────┐ │
│ │ nginx-proxy-manager │ TLS, routing │
│ │ (:81 admin, loopback) │ │
│ └──┬─────┬─────┬─────┬───┘ │
│ │ │ │ │ │
│ ┌───────▼─┐ ┌─▼─────┐ ┌───▼──┐ ┌▼──────┐ │
│ │frontend │ │backend│ │ ws │ │ genai │ Docker │
│ │ (nginx) │ │ :4000 │ │:8080 │ │ :8000 │ bridge │
│ └─────────┘ └───┬───┘ └──┬───┘ └───────┘ │
│ │ │ │
│ host.docker.internal -> 172.17.0.1 │
│ │ │ │
│ ┌───────────────▼────────▼────────────────────┐ │
│ │ host services (systemd, NOT containers) │ │
│ │ postgresql :5432 redis :6379 │ │
│ └─────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘

containers on the private `rexial-network` bridge
```

Only nginx is exposed. Everything else is reachable only inside the Docker
network.
**PostgreSQL and Redis run on the host**, installed with apt, not as
containers. The containers that need them declare
`extra_hosts: host.docker.internal:host-gateway`, which resolves to the Docker
bridge gateway. The host services must bind that interface, and the firewall
must restrict those ports to the bridge subnet — see
[VPS-DEPLOYMENT.md](VPS-DEPLOYMENT.md).

> **Warning:** Port **81** is the nginx-proxy-manager admin UI. It is published on the
>host, so it should be firewalled to trusted IPs — anyone who reaches it can
>re-route your traffic.
Only ports 80 and 443 are public. The nginx admin UI is bound to `127.0.0.1:81`
and reached through an SSH tunnel.

Images are built by GitHub Actions and tagged `:latest` **and** `:<commit-sha>`;
the deploy pins the SHA so rollback is exact. See
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ The platform evolves in multiple versions:
boundaries, data model, request flows, caching, and the design decisions
behind them.

**[VPS deployment guide →](VPS-DEPLOYMENT.md)** — step-by-step setup on a
single server, with PostgreSQL and Redis installed on the host rather than in
containers.

---

## Getting Started
Expand Down
71 changes: 18 additions & 53 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## Production stack.
##
## PostgreSQL and Redis run on the HOST (installed via apt), not as containers.
## Containers reach them through `host.docker.internal`, which `extra_hosts`
## maps to the Docker bridge gateway (172.17.0.1).
##
## The host services must listen on that bridge and be firewalled to it.
## See VPS-DEPLOYMENT.md for the full setup.

services:

nginx:
Expand All @@ -7,7 +16,9 @@ services:
ports:
- "80:80"
- "443:443"
- "81:81"
# Admin UI on localhost only - tunnel in:
# ssh -L 8181:localhost:81 user@your-vps
- "127.0.0.1:81:81"

volumes:
- nginx_data:/data
Expand Down Expand Up @@ -50,9 +61,9 @@ services:

networks:
- rexial-network
depends_on:
db:
condition: service_healthy
# Reach PostgreSQL and Redis running on the host.
extra_hosts:
- "host.docker.internal:host-gateway"

# now run the migrations
command: >
Expand All @@ -73,9 +84,9 @@ services:
- .env.prod
networks:
- rexial-network
depends_on:
db:
condition: service_healthy
# Reach PostgreSQL and Redis running on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
command: >
sh -c "pnpm --filter @repo/db run db:generate:prod &&
pnpm run start"
Expand All @@ -102,57 +113,11 @@ services:
retries: 5
start_period: 60s

db:
image: postgres:15-alpine
container_name: rexial_db_prod
restart: always
env_file:
- .env.prod
volumes:
- Rexial_data:/var/lib/postgresql/data
networks:
- rexial-network
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d Rexial" ]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s

redis:
image: redis:7-alpine
container_name: rexial_redis_prod
restart: always
env_file:
- .env.prod
command:
- redis-server
- --requirepass
- ${REDIS_PASSWORD}
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
- --appendonly
- yes
volumes:
- redis_data:/data
networks:
- rexial-network
healthcheck:
test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s

networks:
rexial-network:
driver: bridge

volumes:
Rexial_data:
genai_uploads:
nginx_data:
letsencrypt:
redis_data:
Loading