diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f416bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +**/node_modules +**/dist +**/.turbo +**/coverage +.git +.github +supabase/.branches +supabase/.temp +**/*.log +.env +.env.* +!.env.docker.example diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..e001253 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,12 @@ +# Executagent Studio — Docker/VPS deployment. Copy to `.env` next to docker-compose.yml. + +# Managed Supabase project the Studio talks to (the anon/publishable key is browser-safe). +SUPABASE_URL=https://YOUR-PROJECT.supabase.co +SUPABASE_ANON_KEY=your-anon-or-publishable-key + +# Public hostname for the Studio (Traefik routes this to the container). +STUDIO_HOST=studio.yourdomain.com + +# Existing Traefik setup on the VPS. +PROXY_NETWORK=proxy +TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/.gitignore b/.gitignore index 94f4530..69e24e8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ .env .env.* !.env.example +!.env.docker.example supabase/.env supabase/.env.* diff --git a/README.md b/README.md index 7fadabc..1ca9120 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,18 @@ pnpm dev:studio # Vite dev server for the Studio UI Copy `.env.example` to `.env` and fill in Supabase + provider keys. Secrets are never committed. DB schema and Edge Functions are managed via Supabase migrations under `supabase/`. + +## Deploy (Docker on a VPS) + +The Studio ships as a runtime-configurable nginx container that sits behind your existing +reverse proxy (Traefik) and talks to the managed Supabase backend: + +```bash +cp .env.docker.example .env # SUPABASE_URL, SUPABASE_ANON_KEY, STUDIO_HOST +docker network create proxy # if your proxy network doesn't exist yet +docker compose up -d --build +``` + +Only port 80 is exposed on the shared `proxy` network (no host ports), so it coexists with +other projects. Full details — including the nginx-proxy variant — in +[`docs/07-deploy-docker.md`](docs/07-deploy-docker.md). diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile new file mode 100644 index 0000000..b392d2f --- /dev/null +++ b/apps/studio/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +# ---- build stage: compile the Studio static bundle ---- +FROM node:22-alpine AS build +WORKDIR /repo +RUN corepack enable + +# Install with the workspace manifests first (better layer caching). +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages/config/package.json packages/config/ +COPY packages/core/package.json packages/core/ +COPY packages/providers/package.json packages/providers/ +COPY apps/studio/package.json apps/studio/ +RUN pnpm install --frozen-lockfile + +# Copy sources and build only the Studio app. +COPY . . +RUN pnpm --filter @executagent/studio build + +# ---- runtime stage: serve with nginx, config injected at startup ---- +FROM nginx:1.27-alpine AS runtime +COPY apps/studio/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/studio/dist /usr/share/nginx/html +COPY apps/studio/docker-entrypoint.sh /docker-entrypoint.d/99-executagent-config.sh +RUN chmod +x /docker-entrypoint.d/99-executagent-config.sh +EXPOSE 80 +# nginx:alpine runs scripts in /docker-entrypoint.d/ then starts nginx, so the +# default CMD is kept; our script writes config.js before nginx serves it. diff --git a/apps/studio/docker-entrypoint.sh b/apps/studio/docker-entrypoint.sh new file mode 100644 index 0000000..b983ff2 --- /dev/null +++ b/apps/studio/docker-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# nginx:alpine runs this hook (from /docker-entrypoint.d/) BEFORE starting nginx. +# Generate runtime config from container env so one image can target any Supabase +# project without rebuilding. Only the publishable anon key is exposed (safe in-browser). +cat > /usr/share/nginx/html/config.js < Executagent Studio + + diff --git a/apps/studio/nginx.conf b/apps/studio/nginx.conf new file mode 100644 index 0000000..a72ad2c --- /dev/null +++ b/apps/studio/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Never cache the runtime config or the HTML entrypoint. + location = /config.js { + add_header Cache-Control "no-store"; + } + location = /index.html { + add_header Cache-Control "no-store"; + } + + # Hashed assets are immutable. + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # SPA fallback. + location / { + try_files $uri $uri/ /index.html; + } + + # Baseline security headers (TLS/HSTS handled by the upstream reverse proxy). + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; +} diff --git a/apps/studio/public/config.js b/apps/studio/public/config.js new file mode 100644 index 0000000..0be77ed --- /dev/null +++ b/apps/studio/public/config.js @@ -0,0 +1,7 @@ +// Runtime config placeholder. In production the Docker entrypoint overwrites this +// file from container env (SUPABASE_URL / SUPABASE_ANON_KEY). Left empty here so +// local `pnpm dev` falls back to Vite's VITE_* env (or DEMO mode). +window.__EXECUTAGENT_CONFIG__ = { + SUPABASE_URL: "", + SUPABASE_ANON_KEY: "", +}; diff --git a/apps/studio/src/lib/supabaseClient.js b/apps/studio/src/lib/supabaseClient.js index 1b08ac4..448b97c 100644 --- a/apps/studio/src/lib/supabaseClient.js +++ b/apps/studio/src/lib/supabaseClient.js @@ -1,9 +1,13 @@ -// Supabase browser client. `configured` is false when env vars are absent, so the -// UI can fall back to a clearly-labeled DEMO mode for offline development. +// Supabase browser client. Config is read at RUNTIME from window.__EXECUTAGENT_CONFIG__ +// (injected by /config.js — generated from container env at startup) and falls back +// to Vite build-time env for local `pnpm dev`. `configured` is false when neither is +// present, so the UI shows a clearly-labeled DEMO mode. import { createClient } from "@supabase/supabase-js"; -const url = import.meta.env.VITE_SUPABASE_URL; -const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const runtime = (typeof window !== "undefined" && window.__EXECUTAGENT_CONFIG__) || {}; + +const url = runtime.SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL; +const anonKey = runtime.SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_ANON_KEY; export const configured = Boolean(url && anonKey); export const SUPABASE_URL = url; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1005c8c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +# Executagent Studio — VPS deployment behind an existing reverse proxy. +# +# cp .env.docker.example .env # fill SUPABASE_URL, SUPABASE_ANON_KEY, STUDIO_HOST +# docker compose up -d --build +# +# Frontend-only: the backend stays on the managed Supabase project. The container +# exposes port 80 on the shared proxy network (no host ports), so it coexists with +# your other projects. Traefik labels are included; for nginx-proxy see docs/07. +services: + studio: + build: + context: . + dockerfile: apps/studio/Dockerfile + image: executagent-studio:latest + restart: unless-stopped + environment: + # Read at container startup and written into /config.js (anon key is public). + SUPABASE_URL: ${SUPABASE_URL:?set SUPABASE_URL in .env} + SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY:?set SUPABASE_ANON_KEY in .env} + networks: + - proxy + expose: + - "80" + labels: + - "traefik.enable=true" + - "traefik.docker.network=${PROXY_NETWORK:-proxy}" + - "traefik.http.routers.executagent.rule=Host(`${STUDIO_HOST:?set STUDIO_HOST in .env}`)" + - "traefik.http.routers.executagent.entrypoints=websecure" + - "traefik.http.routers.executagent.tls=true" + - "traefik.http.routers.executagent.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}" + - "traefik.http.services.executagent.loadbalancer.server.port=80" + +# The proxy network is created/owned by your existing Traefik stack. +# Create it once if needed: docker network create proxy +networks: + proxy: + external: true + name: ${PROXY_NETWORK:-proxy} diff --git a/docs/07-deploy-docker.md b/docs/07-deploy-docker.md new file mode 100644 index 0000000..15c8278 --- /dev/null +++ b/docs/07-deploy-docker.md @@ -0,0 +1,61 @@ +# 07 · Deploy with Docker (VPS, behind a reverse proxy) + +Frontend-only container: the Studio static bundle served by nginx, talking to the +**managed Supabase** project. The image takes its config at **runtime** (no rebuild to +re-point), and exposes only port 80 on your shared proxy network — so it coexists with the +other projects on the VPS. + +## What's built + +- `apps/studio/Dockerfile` — multi-stage: build the Vite bundle, serve with `nginx:alpine`. +- `apps/studio/docker-entrypoint.sh` — runs before nginx; writes `/config.js` from container + env (`SUPABASE_URL`, `SUPABASE_ANON_KEY`). The browser reads `window.__EXECUTAGENT_CONFIG__`. +- `apps/studio/nginx.conf` — SPA fallback, immutable caching for `/assets`, no-store for + `config.js`/`index.html`, baseline security headers (TLS/HSTS handled by your proxy). +- `docker-compose.yml` — one `studio` service on the external `proxy` network with Traefik labels. + +## Quick start + +```bash +cp .env.docker.example .env # fill SUPABASE_URL, SUPABASE_ANON_KEY, STUDIO_HOST +docker network create proxy # only if your Traefik network doesn't exist yet +docker compose up -d --build +``` + +Traefik discovers the container via labels and routes `https://$STUDIO_HOST` to it with TLS +from your existing certresolver. No host ports are published. + +### Env (`.env`) + +| Var | Meaning | +| --- | --- | +| `SUPABASE_URL` | Managed project URL (e.g. `https://.supabase.co`). | +| `SUPABASE_ANON_KEY` | Anon/publishable key (browser-safe). | +| `STUDIO_HOST` | Public hostname Traefik routes to the Studio. | +| `PROXY_NETWORK` | Name of your existing proxy network (default `proxy`). | +| `TRAEFIK_CERTRESOLVER` | Your Traefik cert resolver (default `letsencrypt`). | + +## Using nginx-proxy instead of Traefik + +Drop the `labels:` block and add the env the [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) +companion expects, keeping the service on its network: + +```yaml + environment: + SUPABASE_URL: ${SUPABASE_URL} + SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} + VIRTUAL_HOST: ${STUDIO_HOST} + VIRTUAL_PORT: "80" + LETSENCRYPT_HOST: ${STUDIO_HOST} +``` + +## Notes + +- **Backend stays in the cloud.** This deploys only the UI. The edge functions + DB remain on + the Supabase project; see `supabase/` to manage them. Full self-hosting of Supabase is the + documented heavier alternative (`docs/02-scale-vision.md`). +- **Enable anonymous sign-in** (Supabase → Auth) for the demo flow, or wire email/OAuth. +- If `SUPABASE_URL`/`SUPABASE_ANON_KEY` are unset, the entrypoint logs a warning and the UI + runs in clearly-labeled **DEMO** mode. +- Reconfigure without rebuilding: change `.env` and `docker compose up -d` re-runs the + entrypoint, regenerating `config.js`.