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
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
.github
.codebase-memory
.env
.env.*
!.env.example
**/node_modules
**/dist
**/build
**/coverage
**/.cache
**/.tmp
**/*.log
.torollo
.torollo-data
AGENTS.md
CLAUDE.md
doc
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Compose project and host exposure
COMPOSE_PROJECT_NAME=torollo
TOROLLO_BIND_ADDRESS=127.0.0.1
TOROLLO_FRONTEND_PORT=23232

# Used only by compose.dev.yaml, where the browser calls the backend directly.
TOROLLO_BACKEND_PORT=23233

# Host-side Docker socket mounted into the backend container.
# Rootless Docker commonly uses /run/user/<uid>/docker.sock.
TOROLLO_DOCKER_SOCKET=/var/run/docker.sock

# Comma-separated exact browser origins. Leave blank for localhost-only use.
# LAN example: http://192.168.1.50:23232
TOROLLO_ALLOWED_ORIGINS=
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,25 @@ jobs:

- name: Run Tests
run: npm test

compose-build:
runs-on: ubuntu-latest

steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Validate production Compose configuration
run: docker compose config --quiet

- name: Validate development Compose configuration
run: docker compose -f compose.yaml -f compose.dev.yaml config --quiet

- name: Build production images
run: docker compose build

- name: Build development images
run: docker compose -f compose.yaml -f compose.dev.yaml build
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ npm run dev

You can now access the interface at `http://localhost:23232`.

Alternatively, run both services in containers with hot reload:

```bash
docker compose -f compose.yaml -f compose.dev.yaml up --build
```

This requires Docker Compose v2. The backend is available on port `23233`, the frontend on `23232`, and Torollo state is kept in the Compose-managed `torollo-data` volume. Rebuild the images after changing either package's dependencies.

### 4. Testing the Production Build (CLI Mode)
If you want to test how the application runs when bundled via the CLI tool:
```bash
Expand Down
55 changes: 55 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1.7

ARG NODE_IMAGE=node:20.20.2-bookworm-slim
ARG NGINX_IMAGE=nginx:1.28.3-alpine

FROM ${NODE_IMAGE} AS backend-deps
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM backend-deps AS backend-build
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN npm run build

FROM backend-deps AS backend-dev
COPY backend/tsconfig.json ./
COPY backend/src ./src
EXPOSE 23233
CMD ["npm", "run", "dev"]

FROM ${NODE_IMAGE} AS backend-runtime
ENV NODE_ENV=production \
PORT=23233 \
TOROLLO_HOST=0.0.0.0
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=backend-build /app/backend/dist ./dist
COPY roadmaps /app/roadmaps
EXPOSE 23233
CMD ["node", "dist/server.js"]

FROM ${NODE_IMAGE} AS frontend-deps
WORKDIR /app
COPY package.json ./package.json
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM frontend-deps AS frontend-build
COPY frontend ./
RUN npm run build

FROM frontend-deps AS frontend-dev
COPY frontend ./
EXPOSE 23232
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "23232"]

FROM ${NGINX_IMAGE} AS frontend-runtime
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
COPY docker/frontend-env.js /usr/share/nginx/html/env.js
EXPOSE 23232
CMD ["nginx", "-g", "daemon off;"]
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,52 @@ npx torollo start

Open the app, create a project, and hit **Learning** in the topbar — the best first contact with Torollo is a guided roadmap, not an empty canvas. Start with **Deploy a resilient three-tier app**: it walks you from a single web server to a load-balanced, firewalled, database-backed architecture in ten validated steps.

### Run with Docker Compose

The Compose setup builds the frontend and backend, serves them through one local URL, mounts the host Docker socket so Torollo can create lab resources, and persists projects and learning progress in a named volume.

Prerequisites:

- Docker Engine or Docker Desktop with the Docker daemon running.
- Docker Compose v2 (`docker compose version`).

Start the production stack:

```bash
cp .env.example .env # optional: defaults work for localhost
docker compose up --build -d
docker compose logs -f
```

Open `http://localhost:23232`. The first boot can take several minutes while Torollo prepares its node images; follow the backend logs to see progress. Stop the application without deleting its state with:

```bash
docker compose down
```

For development with Vite and nodemon hot reload:

```bash
docker compose -f compose.yaml -f compose.dev.yaml up --build
```

The development override also publishes the backend at `http://localhost:23233`. Source and roadmap changes are bind-mounted; rebuild after changing dependencies.

Compose reads these optional settings from the root `.env` file:

| Variable | Default | Purpose |
|---|---|---|
| `COMPOSE_PROJECT_NAME` | `torollo` | Prefix for containers, networks, and volumes |
| `TOROLLO_BIND_ADDRESS` | `127.0.0.1` | Host address used for published ports |
| `TOROLLO_FRONTEND_PORT` | `23232` | Browser-facing frontend/proxy port |
| `TOROLLO_BACKEND_PORT` | `23233` | Backend port published only by the development override |
| `TOROLLO_DOCKER_SOCKET` | `/var/run/docker.sock` | Host Docker socket to mount into the backend |
| `TOROLLO_ALLOWED_ORIGINS` | empty | Comma-separated extra browser origins |

Project and roadmap-progress data live in the `torollo-data` named volume. `docker compose down` preserves it; `docker compose down -v` permanently deletes it.

> ⚠️ **Docker access:** The socket mount gives the backend control of the host Docker daemon, which is Torollo's core function. Torollo has no authentication, so anyone who can reach the published port inherits that control — the default `TOROLLO_BIND_ADDRESS=127.0.0.1` is what keeps the stack private, not `TOROLLO_ALLOWED_ORIGINS`, which only covers browsers. Read [Self-Hosting & Network Exposure](#self-hosting--network-exposure) before changing it, and do not run untrusted Torollo images. Rootless Docker users can set `TOROLLO_DOCKER_SOCKET=/run/user/<uid>/docker.sock`.

---

## Guided, auto-graded roadmaps
Expand Down Expand Up @@ -85,6 +131,13 @@ To access Torollo from another machine (e.g. a home lab server), opt in explicit
TOROLLO_HOST=0.0.0.0 TOROLLO_ALLOWED_ORIGINS=http://<your-lan-ip>:23232 npx torollo start
```

For Compose, set the equivalent root `.env` values and restart the stack:

```dotenv
TOROLLO_BIND_ADDRESS=0.0.0.0
TOROLLO_ALLOWED_ORIGINS=http://<your-lan-ip>:23232
```

- `TOROLLO_HOST` — address the API binds to (default `127.0.0.1`; set to `0.0.0.0` to listen on all interfaces).
- `TOROLLO_ALLOWED_ORIGINS` — comma-separated list of exact extra origins allowed to call the API from a browser (the address you type into the browser, e.g. `http://192.168.1.5:23232`).

Expand Down
50 changes: 50 additions & 0 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
services:
backend:
image: torollo/backend-dev:local
build:
target: backend-dev
command: npm run dev
environment:
NODE_ENV: development
ports:
- target: 23233
published: ${TOROLLO_BACKEND_PORT:-23233}
host_ip: ${TOROLLO_BIND_ADDRESS:-127.0.0.1}
protocol: tcp
volumes:
- type: bind
source: ./backend/src
target: /app/backend/src
- type: bind
source: ./backend/tsconfig.json
target: /app/backend/tsconfig.json
read_only: true
- type: bind
source: ./roadmaps
target: /app/roadmaps
read_only: true

frontend:
image: torollo/frontend-dev:local
build:
target: frontend-dev
command: npm run dev -- --host 0.0.0.0 --port 23232
environment:
VITE_TOROLLO_BACKEND_PORT: ${TOROLLO_BACKEND_PORT:-23233}
volumes:
- type: bind
source: ./frontend/src
target: /app/frontend/src
- type: bind
source: ./frontend/public
target: /app/frontend/public
- type: bind
source: ./frontend/index.html
target: /app/frontend/index.html
read_only: true
- type: bind
source: ./frontend/vite.config.ts
target: /app/frontend/vite.config.ts
read_only: true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:23232').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
59 changes: 59 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: ${COMPOSE_PROJECT_NAME:-torollo}

services:
backend:
image: torollo/backend:local
build:
context: .
target: backend-runtime
environment:
NODE_ENV: production
PORT: "23233"
TOROLLO_HOST: 0.0.0.0
TOROLLO_ALLOWED_ORIGINS: ${TOROLLO_ALLOWED_ORIGINS:-}
expose:
- "23233"
volumes:
- type: bind
source: ${TOROLLO_DOCKER_SOCKET:-/var/run/docker.sock}
target: /var/run/docker.sock
- type: volume
source: torollo-data
target: /root/.torollo
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:23233/health').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
init: true
restart: unless-stopped

frontend:
image: torollo/frontend:local
build:
context: .
target: frontend-runtime
depends_on:
backend:
condition: service_healthy
ports:
- target: 23232
published: ${TOROLLO_FRONTEND_PORT:-23232}
host_ip: ${TOROLLO_BIND_ADDRESS:-127.0.0.1}
protocol: tcp
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:23232/healthz"]
interval: 10s
timeout: 5s
retries: 6
start_period: 5s
init: true
restart: unless-stopped

volumes:
torollo-data:
1 change: 1 addition & 0 deletions docker/frontend-env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
window.TOROLLO_BACKEND_URL = window.location.origin;
75 changes: 75 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 23232;
server_name _;
server_tokens off;

root /usr/share/nginx/html;
index index.html;

# A literal upstream name is resolved once at startup and cached for the
# lifetime of the container, so nginx would keep proxying to a stale address
# after `docker compose up` recreates the backend. Going through a variable
# defers the lookup to request time, against Docker's embedded DNS.
resolver 127.0.0.11 ipv6=off valid=10s;
set $torollo_backend backend;

# add_header replaces rather than merges across levels: any location
# declaring its own header loses the two below and has to repeat them.
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;

location = /healthz {
access_log off;
default_type text/plain;
return 200 'ok';
}

location = /env.js {
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Cache-Control 'no-store, no-cache, must-revalidate' always;
try_files $uri =404;
}

location /api/ {
proxy_pass http://$torollo_backend:23233;
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;

# Creating a node blocks on pulling its image, which on a cold host runs
# well past the default 60s. Timing out here would report a failure for a
# container the backend goes on to create.
proxy_read_timeout 15m;
}

location = /health {
proxy_pass http://$torollo_backend:23233;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /socket.io/ {
proxy_pass http://$torollo_backend:23233;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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;
proxy_read_timeout 1h;
}

location / {
try_files $uri $uri/ /index.html;
}
}
Loading
Loading