26 KiB
PR Previews (PP)
PP is a self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP automatically provisions an AWS EC2 instance, builds and runs the project, and comments a live preview URL back on the PR. Designed for teams and agents iterating on code without needing local environments.
Purpose
- Provide a shareable live preview of any PR before it's merged.
- Remove the need for reviewers to pull and run code locally.
- Works well in agent-driven workflows where humans review from mobile or external environments.
Tech Stack
- Backend:
rj-webfrom../shsf(same setup pattern), logging + auth middleware + Prisma from../shsfadapted to this project's needs. - Frontend: React + Vite + Tailwind CSS (dark/light mode toggle, responsive).
- Database: PostgreSQL via Prisma (schema at
./prisma/schema.prisma). - CI/CD: Reuse
../shsfCI/CD pipeline; build and push Docker image to the same registry under/pp-previews/core.
Project Structure
./backend/ # Node backend (rj-web)
./frontend/ # React + Vite frontend
./backend/prisma/schema.prisma # Prisma schema
./docker-compose.yml # Compose + Dockerfile for PP itself
Implementation Notes
You're building this from scratch. Refer to ../shsf for patterns on rj-web setup, logging, auth middleware, and Prisma client initialization.
Testing: No automated tests for V1. Build to fully working state locally first. The first real test will be against a live Gitea instance with a real repo. Build as if it will be tested end-to-end immediately.
Data Model (Prisma)
User
PP account.
- id, username, passwordHash, isAdmin, isFounder, createdAt
- giteaUsername, giteaPAT (encrypted), giteaInstanceUrl (e.g.
https://gitea.example.com, no trailing slash) - awsAccessKeyId (encrypted), awsSecretAccessKey (encrypted), awsRegion
RepoConfig
Per-repo settings, one per user per repo. A repo can only be configured by one user — first to save wins; others receive an error.
- id, userId, repoOwner, repoName
- isEnabled (bool)
- giteaWebhookId (string, stored after auto-registration so PP can delete/update it later)
- denyList (string[], Gitea usernames to skip — e.g. dependabot)
- instanceType (string, default from AdminSettings.defaultInstanceType)
- inactivityHours (float, default 12, range 0.5–72)
- port (int, default 3000 — the port the app listens on inside the EC2 instance)
- envVars (JSON key-value)
- preinstallTools (string[] — selected runtime/tooling bootstrap options:
docker,node,python,go,lua,build-essential) - nodeVersion (string, nullable — used when
nodeis selected; defaultlts/*;.nvmrcin the repo wins during deploy) - useDockerCompose (bool)
- composeFilePath (string, nullable — path within repo, default
docker-compose.yml) - aptPackages (string[])
- setupCommands (string[])
- buildCommands (string[])
- postBuildCommands (string[])
- runCommand (string, single — non-compose mode only)
Preview
One record per PR. Persists across pushes; the EC2 instance is reused on each new push (only build + run steps re-run).
- id, repoConfigId, prNumber, prTitle
- commitSha (updated on each push)
- instanceId (AWS EC2 instance ID)
- instanceIp (public IP)
- port (copied from RepoConfig at deploy time)
- status (enum:
PROVISIONING|BUILDING|RUNNING|FAILED|STOPPED|IGNORED) - logs (text, appended each redeploy, capped at AdminSettings.logSizeLimitBytes — oldest lines truncated with a
--- logs truncated ---marker) - pid (int, nullable — non-compose mode only, the background process PID on the EC2 instance)
- sshPrivateKey (encrypted — ephemeral, generated per launch, deleted on instance termination)
- sshKeyName (string — AWS key pair name, for deletion on terminate)
- giteaCommentId (int, nullable — the Gitea comment ID of PP's status comment, for in-place edits)
- lastActivityAt (DateTime — updated on each push webhook AND each PR comment webhook)
- createdAt, updatedAt, stoppedAt
Job
Durable async job queue for deployments and stops.
- id, previewId (nullable), type (enum:
DEPLOY|STOP|INACTIVITY_STOP), status (enum:PENDING|RUNNING|DONE|FAILED) - payload (JSON), error (text, nullable)
- createdAt, startedAt, finishedAt
WebhookToken
Per-user HMAC secret for verifying Gitea webhook payloads.
- id, userId, token (random secret), createdAt
NoConfigComment
Tracks which PRs have already received a "no previews configured" comment to avoid repeat spam.
- id, userId, repoOwner, repoName, prNumber, createdAt
AdminSettings
Single row global config (seeded on first run).
- id
- defaultInstanceType (string, default
t3.medium) - maxConcurrentInstancesPerUser (int, default 5)
- logSizeLimitBytes (int, default 1048576 — 1MB)
- previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged)
- webhookRateLimitPerMinute (int, default 10 — per user)
- contactEmail (string — shown in privacy policy footer)
Backend
Auth
- Session-based auth (reuse shsf middleware).
- Registration endpoint is disabled — only admins can create users via the admin panel.
- First registered user automatically becomes admin and is flagged as
isFounder = true. The founder cannot be demoted or deleted.
Webhook Endpoint
POST /webhook/:userId
Rate limiting: Each user is limited to AdminSettings.webhookRateLimitPerMinute webhook events per minute. Excess requests receive 429 Too Many Requests and are dropped (not queued).
Signature verification: Gitea sends X-Gitea-Signature-256: sha256=<hmac>. PP verifies using the user's WebhookToken.token before doing anything else. Invalid signature → 401.
Accepted Gitea events (set via X-Gitea-Event header):
pull_requestwith actionopened→ trigger new deploypull_requestwith actionsynchronize→ trigger redeploy on existing Previewpull_requestwith actionreopened→ same asopenedpull_requestwith actionclosed→ stop and terminate the previewissue_commentwith actioncreated→ parse for/ppcommands (see PP Commands section)
Webhook handler returns 200 OK immediately after enqueueing a job. All processing is async.
Activity tracking: Update Preview.lastActivityAt = now on both synchronize and issue_comment events.
PP Commands
PP listens for comments on PRs (via issue_comment webhook). A comment is a PP command if it starts with /pp as the first line. Commands are only acted on if posted by: the PR author, or a Gitea user with owner or admin role on the repo (checked via GET /api/v1/repos/{owner}/{repo}/teams or collaborator endpoint).
| Command | Behaviour |
|---|---|
/pp rebuild |
Re-runs build + start steps on the existing EC2 instance (same as a new push but without a git pull). |
/pp stop |
Stops and terminates the preview instance. Sets status to STOPPED. |
/pp start |
If STOPPED: re-provisions a new EC2 and deploys. If IGNORED: un-ignores AND immediately triggers a fresh deploy. |
/pp logs |
PP posts a new comment with the last 50 lines of Preview.logs in a fenced code block. |
/pp ignore |
Sets Preview.status = IGNORED. Future pushes and commands (except /pp start) are silently ignored. If no Preview exists yet, creates one in IGNORED state so future pushes are skipped. |
PP does not react to its own comments (check that the commenter's username != User.giteaUsername for the configured user).
AWS EC2 Management
Required IAM permissions (document in the setup wizard UI with a copy-pasteable JSON policy):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups",
"ec2:CreateKeyPair",
"ec2:DeleteKeyPair",
"ec2:CreateTags",
"sts:GetCallerIdentity"
],
"Resource": "*"
}]
}
EC2 instance tagging — every instance PP launches must be tagged:
pp:managed = true
pp:userId = <User.id>
pp:repo = <repoOwner>/<repoName>
pp:prNumber = <prNumber>
pp:previewId = <Preview.id>
EC2 setup per preview:
- Generate an ephemeral RSA key pair. Store the private key encrypted in
Preview.sshPrivateKey. Import the public key to AWS aspp-preview-<previewId>and store the name inPreview.sshKeyName. - Create a security group named
pp-preview-<previewId>in the default VPC. Allow inbound: all traffic from0.0.0.0/0and::/0(all protocols, including ICMP, and all ports). - Launch instance:
- AMI: Ubuntu 22.04 LTS (hardcode a per-region AMI map, or resolve via SSM
resolve:ssm:/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id) - Instance type from
RepoConfig.instanceType - Key pair:
pp-preview-<previewId> - Security group:
pp-preview-<previewId> - User data bootstrap script (runs once on first boot):
#!/bin/bash apt-get update -y apt-get install -y ca-certificates curl git unzip - Repo-selected preinstall tools are installed over SSH after clone: Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages.
- All instance tags as above.
- AMI: Ubuntu 22.04 LTS (hardcode a per-region AMI map, or resolve via SSM
- Poll
DescribeInstancesuntil status =runningand the instance has a public IP. - Poll TCP port 22 until it accepts connections (max 5 minutes, then fail).
Orphan cleanup on startup: On PP startup, call DescribeInstances with filter tag:pp:managed=true and compare against all non-STOPPED Preview records. Any instance with no matching Preview, or whose matching Preview is STOPPED, is terminated and its security group deleted.
On instance termination:
- Delete the AWS key pair (
DeleteKeyPairusingPreview.sshKeyName). - Delete the security group
pp-preview-<previewId>. - Terminate the instance.
- Null out
Preview.sshPrivateKeyandPreview.sshKeyNamein the DB.
Deployment Flow
Each PR has exactly one Preview record. The EC2 instance is provisioned once and reused on subsequent pushes.
First push (PR opened / no existing Preview, or previous Preview is STOPPED)
Set Preview status to PROVISIONING.
- Check
AdminSettings.maxConcurrentInstancesPerUser— count PROVISIONING + BUILDING + RUNNING previews for this user. If at limit: post a Gitea comment on the PR explaining the limit is reached, mark job FAILED, stop. - Provision EC2 — as described above.
- SSH in. All subsequent commands run over SSH.
- Clone repo:
- Normal PR:
git clone https://<giteaUsername>:<PAT>@<giteaInstanceUrl>/<owner>/<repo>.git /opt/app - Fork PR: clone from the fork's URL (
payload.pull_request.head.repo.clone_url, injecting PAT auth). cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr
- Normal PR:
- Install selected preinstall tools and apt packages: Docker + Compose, Node (using
.nvmrcif present, otherwiseRepoConfig.nodeVersion), Python, Go, Lua, build tools, and customRepoConfig.aptPackages. - Write
.env: Write/opt/app/.envfromRepoConfig.envVars. - Setup commands (if any): run each in order. Only runs on first provision.
- Set Preview status to
BUILDING. - Build commands: run each in order.
- Post-build commands (if any): run each in order.
- Start:
- Docker Compose:
cd /opt/app && sudo docker compose -f <composeFilePath> up -d --build --force-recreate - Non-compose: run
runCommandin background vianohup ... > /opt/app/pp.log 2>&1 &, capture PID → save toPreview.pid.
- Docker Compose:
- Set Preview status to
RUNNING. StoreinstanceIp,port,commitSha,lastActivityAt = now. - Comment on PR — post initial status comment (see PR Commenting section). Store the comment ID in
Preview.giteaCommentId.
Subsequent push (synchronize / existing RUNNING or FAILED Preview)
- SSH into existing instance using
Preview.instanceIpand decryptedPreview.sshPrivateKey. - Stop current process:
- Non-compose:
kill <Preview.pid>(SIGTERM, wait 5s, SIGKILL if still running). - Docker Compose:
cd /opt/app && sudo docker compose -f <composeFilePath> down.
- Non-compose:
- Pull latest:
cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD - Re-run selected preinstall checks (
.nvmrcor runtime selections may have changed). - Overwrite
.env— re-write from currentRepoConfig.envVars. - Set Preview status to
BUILDING. Append\n--- Redeploy: <commitSha> ---\ntoPreview.logs. - Build commands — re-run each.
- Post-build commands — re-run if defined.
- Start — same as first provision start step. Update
Preview.pid,commitSha,lastActivityAt = now. Set status toRUNNING. - Update PR comment — edit the existing comment in-place using
Preview.giteaCommentId(see PR Commenting section).
If any step fails: Set status to FAILED. Append the error output to Preview.logs. Edit the PR comment to show failure with the last few log lines. Stop processing.
All log output is streamed to Preview.logs via WebSocket to the frontend (rj-web WebSocket support). Enforce log size cap: if Preview.logs exceeds AdminSettings.logSizeLimitBytes, drop oldest lines and prepend --- logs truncated ---.
Inactivity Detection
Preview.lastActivityAtis updated on eachsynchronizewebhook AND eachissue_commentwebhook for the PR.- A background cron job runs every 30 minutes. Any Preview where
lastActivityAt + inactivityHours < nowandstatus = RUNNINGgets aSTOPjob enqueued. - On stop: terminate the EC2 instance, post a final PR comment, set status to
STOPPED.
Job Queue
Webhook handler returns 200 OK immediately and enqueues a Job. A background worker processes jobs:
- Per-PR serialisation: at most one active Job per
previewIdat a time. - New deploy cancels running deploy: if a
DEPLOYjob is alreadyRUNNINGfor a PR and a newDEPLOYarrives, the running job's SSH session is aborted and the new job takes over immediately. - On PP startup: any Jobs left in
RUNNINGstate (from a crash) are reset toPENDINGand requeued. - Job status (PENDING / RUNNING / DONE / FAILED) is visible on the preview detail page in the UI.
Repo Discovery & Webhook Auto-Registration
- When the user saves their Gitea credentials, PP fetches all repos via
GET <giteaInstanceUrl>/api/v1/repos/search?limit=50&token=<PAT>(paginate as needed). - These appear in the Repo Configuration page for the user to enable.
- When the user enables a repo: PP calls
POST <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooksto register a webhook with:type: giteaconfig.url:https://<PP_BASE_URL>/webhook/<userId>config.secret:WebhookToken.tokenconfig.content_type: jsonevents: ["pull_request", "issue_comment"]- Store the returned hook ID in
RepoConfig.giteaWebhookId.
- When the user disables or deletes a repo config: PP warns the user that the Gitea webhook will be deleted, and on confirmation calls
DELETE <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks/<giteaWebhookId>. - Repos without a RepoConfig that trigger a webhook: PP checks
NoConfigComment— if no entry exists foruserId + repoOwner + repoName + prNumber, PP posts one comment ("No previews configured for this repo...") and creates aNoConfigCommentrecord. - Repo ownership conflict: If a user tries to enable a repo that already has a
RepoConfigowned by another user, return an error: "This repo is already configured by another user."
Webhook Secret Rotation
When the user regenerates their webhook secret:
- Generate a new
WebhookToken.token. - For every
RepoConfigbelonging to this user that has agiteaWebhookId, callPATCH <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks/<giteaWebhookId>to update the secret. - Old secret is invalid immediately.
AWS Credential Validation
When the user saves AWS credentials, PP calls sts:GetCallerIdentity. Show inline success ("Connected as arn:aws:iam::...") or error ("Invalid credentials").
Gitea URL Validation
When the user saves their Gitea instance URL, PP calls GET <giteaInstanceUrl>/api/v1/version. Show inline success (Gitea version) or error.
DB Cleanup (background cron, daily)
Delete Preview records where status IN (STOPPED, FAILED) and stoppedAt < now - AdminSettings.previewRetentionDays days. Also delete associated Job records.
Admin Panel (/admin)
Accessible only to isAdmin users. Has its own UI section, separate from the regular user dashboard.
User Management
- Create users (username + password).
- Edit username or password of any user.
- Delete users (cannot delete
isFounderuser). - Promote users to admin (cannot demote
isFounderuser). - Registration endpoint is always disabled — admin creates all accounts.
Global Settings
- Default EC2 instance type — shown as the default in all repo configs.
- Max concurrent EC2 instances per user — when a user hits this, new deploys are rejected with a PR comment.
- Webhook rate limit — max events per user per minute (default 10).
- Log size cap — max bytes per Preview.logs before truncation (default 1MB).
- Preview retention — days before STOPPED/FAILED records are purged (default 30).
- Contact email — shown in the privacy policy footer.
Admin Preview Dashboard
- Shows all previews across all users (separate from the user's own dashboard).
- Per preview: user, repo, PR number, status, instance IP, created at.
- Admin can stop any preview.
User Panel
Account Settings (/settings)
- Username — editable.
- Password — change password.
- Gitea Instance URL — e.g.
https://gitea.example.com. Validated on save (GET /api/v1/version). - Gitea Username — the account that PP will comment as.
- Gitea PAT — write-only display (show
••••••••after save). Must have scopes:repository(read),issue(write),admin:repo_hook(for webhook management). Show a tooltip explaining required scopes with a link to Gitea's token settings page. - AWS Access Key ID — write-only display after save. Validated on save via
sts:GetCallerIdentity. - AWS Secret Access Key — write-only display after save.
- AWS Region — dropdown of all AWS regions.
- Webhook URL — read-only.
https://<PP_BASE_URL>/webhook/<userId>. This is the "Target URL" in Gitea's webhook settings. Copy button included. - Webhook Secret — read-only (separate from the URL). This is the "Secret" field in Gitea's webhook settings. Regenerate button (rotates secret and auto-updates all registered hooks in Gitea with a confirmation prompt).
Note displayed in settings: "PP will post PR comments as your Gitea account (@username). Make sure your PAT has 'issue' write permission."
Repo Configuration (/repos)
- List of repos fetched from Gitea (refresh button). Each repo shows a toggle to enable/disable previews.
- Enabling a repo auto-registers the webhook in Gitea. Disabling warns and then deletes it.
- A repo already configured by another PP user shows a lock icon and "Claimed by another user" — the toggle is disabled.
- Expanded per-repo config:
- Deny list — tag input of Gitea usernames to skip.
- EC2 instance type — searchable select (t3.medium, t3.large, t3a.medium, t3a.large, t4g.medium, t4g.large, m5.large, c5.large) + custom text input. Show estimated hourly cost next to each option.
- Inactivity kill timer — slider 0.5h to 72h with labelled stops.
- App port — number input (default 3000).
- Environment variables — key/value editor, values masked. Add/remove rows.
- Preinstall options — checkboxes for Docker + Compose, Node.js (with version), Python, Go, Lua, build tools, plus custom apt packages.
- Docker Compose toggle — if on: require Docker, show compose file path input (default
docker-compose.yml), hide manual command fields. - Commands (shown for both modes unless noted):
- Apt packages — tag input (compose + non-compose)
- Setup commands — ordered list, add/remove/reorder (compose + non-compose, run once on first provision)
- Build commands — ordered list (non-compose only; compose uses
docker compose up --build) - Post-build commands — ordered list, optional (non-compose only)
- Run command — single text input (non-compose only)
Preview Dashboard (/ or /previews)
- Cards for all of this user's previews across repos.
- Card shows: repo name, PR number + title, status badge, preview URL (clickable when RUNNING), commit SHA, last updated.
- Click into a preview to see:
- Full log viewer (WebSocket — live stream while active, monospace, auto-scroll, ANSI codes stripped).
- Current job status and queue position if PENDING.
- All previous redeploy separators visible inline in the log.
- Status history (PROVISIONING → BUILDING → RUNNING etc. with timestamps).
PR Commenting
PP comments using the configured user's Gitea PAT (comments appear as their account). There is no bot account.
One comment per deploy cycle, edited in-place. PP stores the comment ID in Preview.giteaCommentId and calls PATCH /api/v1/repos/{owner}/{repo}/issues/comments/{id} to update it. A new comment is posted fresh at the start of each new push/redeploy cycle (replacing giteaCommentId with the new comment's ID).
Comment format — Markdown:
## 🚀 PR Preview — `<repoOwner>/<repoName>` #<prNumber>
**Status:** 🟡 Provisioning EC2 instance...
**Commit:** `<sha>`
**Updated:** <timestamp>
---
_Powered by [PR Previews](<PP_BASE_URL>)_
Status line updates as the deploy progresses:
🟡 Provisioning EC2 instance...🟡 Building... (EC2 ready at <ip>)🟢 Live at http://<ip>:<port>🔴 Failed — last log lines:(followed by a fenced code block with the last 10 lines of logs)⚫ Stopped (inactivity timeout / PR closed / manual stop)
On a new redeploy, PP posts a new comment (fresh cycle) and updates giteaCommentId.
Setup Wizard (first login)
Shown to a user on first login if any of the required fields (Gitea URL, PAT, AWS credentials) are unset. Skippable at any point. Steps:
- Welcome — brief explanation of what PP does.
- Gitea Connection — Gitea instance URL + PAT. Validate on "Next". Show required PAT scopes with a link to Gitea settings.
- AWS Setup — Access Key ID, Secret, Region. Show the required IAM policy JSON (copy button). Validate credentials on "Next".
- Your Webhook — show the webhook URL and secret. Explain where to find these in Gitea's UI (per-repo or org-level webhook settings). Note: PP auto-registers per-repo webhooks when you enable a repo — this step is just for reference.
- Enable your first repo — show the repo list, let the user enable one and fill in its config. On save, PP registers the webhook automatically.
- Done — link to the preview dashboard.
UI Design
- Dark/light mode toggle, persisted to localStorage.
- Animated route transitions and status badge updates.
- Status badges: ⚫ grey (stopped/ignored), 🟡 yellow (provisioning/building), 🟢 green (running), 🔴 red (failed).
- Log viewer: monospace font, auto-scroll to bottom, ANSI codes stripped, redeploy separator lines visually distinct (dimmed/italic).
- Responsive layout — usable on mobile for monitoring previews on the go.
- Toast notifications for save success/failure.
- Confirmation dialogs for: deleting a user, stopping a preview, regenerating webhook secret, disabling a repo (which deletes the Gitea webhook).
- Inline validation feedback on settings fields (Gitea URL, AWS credentials, PAT) that tests the connection on save.
- Contextual tooltips on instance type selector (cost estimates), PAT field (required scopes), inactivity timer (what happens when it fires).
Docker / Deployment
./docker-compose.yml runs PP itself (not preview instances — those are EC2):
pp-backend— Node backend.pp-frontend— Vite build served statically (or via backend).pp-db— PostgreSQL.- Volume for Prisma migrations.
Required environment variables:
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
SESSION_SECRET |
Session signing secret |
PP_BASE_URL |
Public URL of this PP instance (used in webhook URLs and PR comment links) |
ENCRYPTION_KEY |
AES-256 key for encrypting PAT, AWS secrets, SSH private keys at rest |
Security Notes
- Gitea PAT, AWS credentials, and SSH private keys are encrypted at rest with AES-256 using
ENCRYPTION_KEY. - Webhook HMAC-SHA256 signature is verified before any payload is processed. Invalid signatures return
401. - Webhook rate limiting (default 10 req/min/user) prevents floods.
- EC2 security groups are scoped per preview — created on provision, deleted on termination.
- SSH keys are ephemeral per launch. Private key is stored encrypted in the DB and nulled out on instance termination.
- Users can only see and manage their own previews, repos, and credentials.
- Admins can see all previews in a dedicated admin UI section.
- The repo config ownership model (first-to-save) prevents two users from deploying conflicting previews for the same repo.
Privacy Policy
A generic self-hosted app privacy policy is included in the footer. It covers:
- What data PP stores (user credentials, Gitea PAT encrypted at rest, AWS credentials encrypted at rest, preview logs, PR metadata).
- That credentials are used only to operate the preview service and are never shared with third parties.
- That EC2 instances are launched in the user's own AWS account — PP does not have access to user data on those instances beyond what it deploys.
- Log retention policy (
previewRetentionDays). - How to contact the instance administrator (configurable contact email in admin settings).
- No analytics, no tracking, no external data sharing.
Out of Scope for V1
- GitHub/GitLab support (Gitea only).
- Custom domains for previews (EC2 public IP + port only).
- Preview instance autoscaling.
- Billing or usage tracking.
- Automated tests (manual QA first, tests added in V2).
- Multi-port previews (one port per repo config only).