Overview
Create preview environments for PRs so that doce.dev can test changes before merging. Each PR should get a dedicated, isolated environment with full Docker support to build and run project containers.
Requirements
- Only same-repo branches (no forks) - PRs from forks should not trigger previews
- Multiple concurrent PR environments supported
- Delete on close - clean up preview resources when PR is closed
- Full Docker access - preview environments must be able to run
docker and docker compose commands
- Self-configured - admin user and OpenRouter API key are configured on first visit (not pre-configured in CI)
Architecture
Host Requirements (VPS)
- Docker Engine +
docker compose plugin
- Node.js 20 + pnpm
- Reverse proxy (Caddy recommended)
- Wildcard DNS:
*.preview.doce.dev → VPS IP (via Vercel DNS)
Per-PR Isolation
Each PR preview needs:
- Directory:
/opt/doce/previews/pr-{NUMBER}/ (full repo checkout at PR SHA)
- Data:
data/ subdirectory with isolated SQLite DB and project files
- Port: Unique port in range
48000-49000 (persistent across PR updates)
- Service:
doce-preview@{NUMBER}.systemd unit
- Subdomain:
pr-{NUMBER}.preview.doce.dev
Key Technical Details
- doce.dev shells out to
docker compose directly (see src/server/docker/compose.ts)
- Data paths are relative to
process.cwd() (see src/server/projects/paths.ts)
- DB path is configurable via
DB_FILE_NAME env var (see src/server/db/client.ts)
- Each preview runs as a separate Node process with isolated working directory
Implementation Checklist
1. VPS Bootstrap
2. Process Management
3. Port Allocation System
4. Caddy Configuration
5. GitHub Actions Workflow
Create .github/workflows/pr-preview.yml with:
Triggers:
pull_request → opened, reopened, synchronize (deploy)
pull_request → closed (teardown)
Deploy job (same-repo only):
jobs:
deploy-preview:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: SSH and deploy
run: |
# 1. Ensure PR directory exists
# 2. Fetch/update repo to PR SHA
# 3. Allocate port (if first time)
# 4. Write .env with PORT, HOST, DB_FILE_NAME
# 5. pnpm install && pnpm build
# 6. systemctl restart doce-preview@{PR_NUMBER}
# 7. Add Caddy snippet and reload
# 8. Comment PR with preview URL
Teardown job:
jobs:
teardown-preview:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: SSH and teardown
run: |
# 1. systemctl stop doce-preview@{PR_NUMBER}
# 2. Remove Caddy snippet and reload
# 3. Release port
# 4. Optionally delete PR directory
Secrets needed:
SSH_PRIVATE_KEY - for accessing the VPS
VPS_HOST - VPS IP/hostname
VPS_USER - SSH user (e.g., root or admin)
6. Environment Variables Per PR
Create .env file in each PR directory:
PORT=<allocated_port>
HOST=127.0.0.1
DB_FILE_NAME=/opt/doce/previews/pr-{NUMBER}/data/db.sqlite
7. Security Considerations
- Only deploy previews for same-repo PRs (Docker access is powerful)
- Preview services run on localhost only (127.0.0.1), accessed via reverse proxy
- Preview host needs access to Docker daemon (add user to
docker group)
Example Preview Flow
- PR #123 opened from branch
feature/new-login
- GitHub Actions deploys to
/opt/doce/previews/pr-123/
- Allocates port
48015, stores in PORT file
- Creates Caddy snippet →
pr-123.preview.doce.dev
- Preview available at
https://pr-123.preview.doce.dev
- User visits URL, configures admin + OpenRouter on first setup
- User can create projects, run OpenCode agents, build containers
- PR closed → preview stopped, Caddy snippet removed, port released
Files to Create/Modify
.github/workflows/pr-preview.yml - main workflow
scripts/deploy-preview.sh - helper script for SSH deploy (optional)
scripts/teardown-preview.sh - helper script for SSH teardown (optional)
systemd/doce-preview@.service - systemd unit template
caddy/Caddyfile.snippet.example - example Caddy snippet
Out of Scope (for now)
- Fork PR previews
- Pre-configured OpenRouter key
- Custom domains per PR
- Preview sharing via link token
- Metrics/log aggregation across previews
Overview
Create preview environments for PRs so that doce.dev can test changes before merging. Each PR should get a dedicated, isolated environment with full Docker support to build and run project containers.
Requirements
dockeranddocker composecommandsArchitecture
Host Requirements (VPS)
docker composeplugin*.preview.doce.dev→ VPS IP (via Vercel DNS)Per-PR Isolation
Each PR preview needs:
/opt/doce/previews/pr-{NUMBER}/(full repo checkout at PR SHA)data/subdirectory with isolated SQLite DB and project files48000-49000(persistent across PR updates)doce-preview@{NUMBER}.systemdunitpr-{NUMBER}.preview.doce.devKey Technical Details
docker composedirectly (seesrc/server/docker/compose.ts)process.cwd()(seesrc/server/projects/paths.ts)DB_FILE_NAMEenv var (seesrc/server/db/client.ts)Implementation Checklist
1. VPS Bootstrap
A *.preview.doce.dev → <VPS_IP>/opt/doce/previewsdirectory structure2. Process Management
doce-preview@.serviceWorkingDirectory=/opt/doce/previews/pr-%i/opt/doce/previews/pr-%i/.envnode dist/server/entry.mjsRestart=always3. Port Allocation System
/opt/doce/previews/ports/)48000-49000for each new PR/opt/doce/previews/pr-{NUMBER}/PORT4. Caddy Configuration
pr-123.preview.doce.dev:5. GitHub Actions Workflow
Create
.github/workflows/pr-preview.ymlwith:Triggers:
pull_request→opened,reopened,synchronize(deploy)pull_request→closed(teardown)Deploy job (same-repo only):
Teardown job:
Secrets needed:
SSH_PRIVATE_KEY- for accessing the VPSVPS_HOST- VPS IP/hostnameVPS_USER- SSH user (e.g.,rootoradmin)6. Environment Variables Per PR
Create
.envfile in each PR directory:7. Security Considerations
dockergroup)Example Preview Flow
feature/new-login/opt/doce/previews/pr-123/48015, stores inPORTfilepr-123.preview.doce.devhttps://pr-123.preview.doce.devFiles to Create/Modify
.github/workflows/pr-preview.yml- main workflowscripts/deploy-preview.sh- helper script for SSH deploy (optional)scripts/teardown-preview.sh- helper script for SSH teardown (optional)systemd/doce-preview@.service- systemd unit templatecaddy/Caddyfile.snippet.example- example Caddy snippetOut of Scope (for now)