Deploy helPRs from zero to a running instance. By the end you'll have helPRs connected to your GitHub org, running skills against your PRs.
- Docker (20.10+) and Docker Compose (v2)
- A GitHub account with permission to create a GitHub App
- A Claude account -- you'll generate an OAuth token with
claude setup-token(or use an Anthropic API key) - A server with a public URL if you want GitHub webhooks (or use a tunnel like ngrok for local dev)
helPRs itself is lightweight (API + web + Postgres). The real footprint comes from the ephemeral claude-runner containers it spawns per session -- each one runs Node + Claude Code CLI, clones the target repo, and holds it in memory for the duration of the skill (typically 5-15 min).
| Profile | vCPU | RAM | Disk | Concurrency |
|---|---|---|---|---|
| Minimum (try it out, 1 user, 1 session at a time) | 2 | 2 GB | 10 GB | 1 |
| Recommended (small team, a few concurrent sessions) | 2-4 | 4 GB | 20 GB | 3-5 |
| Comfortable (larger team, many parallel PRs) | 4-8 | 8 GB+ | 40 GB+ | 10+ |
Each running claude-runner container peaks around 300-600 MB RSS and one core. Plan ~1 GB of RAM headroom per concurrent session you expect, on top of the base ~1 GB used by the API + Postgres + web. Disk usage grows with the size of the repos cloned at runtime (shallow clones, so it's small; cleaned up on container exit).
Runners are killed after CONTAINER_TTL_SECONDS (default 15 min). Lower it if your host is tight on RAM.
Go to github.com/settings/apps/new and fill in:
| Field | Value |
|---|---|
| GitHub App name | helPRs (or any unique name) |
| Homepage URL | Your domain, e.g. https://yourdomain.com |
| Callback URL | https://api.yourdomain.com/api/v1/auth/github/callback |
| Webhook URL | https://api.yourdomain.com/api/v1/webhooks/github |
| Webhook secret | Generate one: python -c "import secrets; print(secrets.token_urlsafe(32))" |
!!! note "URL format depends on your routing setup"
The examples above use a subdomain setup (api.yourdomain.com).
If you use path-based routing on a single domain, replace with
https://yourdomain.com/api/v1/auth/github/callback and
https://yourdomain.com/api/v1/webhooks/github.
Under Repository permissions:
| Permission | Access |
|---|---|
| Contents | Read-only |
| Pull requests | Read and write |
| Metadata | Read-only (auto-granted) |
Under Organization permissions:
| Permission | Access |
|---|---|
| Members | Read-only |
Subscribe to these webhook events:
- Installation -- tracks app installs/uninstalls
- Pull request -- triggers session creation on PR open/sync
- Note the App ID (shown at the top of the app settings page)
- Note the Client ID and generate a Client secret (under "OAuth credentials")
- Note the App slug (the URL-friendly name shown in the URL:
github.com/settings/apps/<slug>) - Generate a private key (bottom of the page) -- downloads a
.pemfile - Prepare the private key for deployment (see below)
The PEM key must be provided to helPRs as the GITHUB_APP_PRIVATE_KEY environment variable. The format depends on your deployment method:
=== ".env file (Docker Compose, VPS)"
Docker Compose `env_file` does not support multi-line values. Base64-encode the PEM:
```bash
base64 -i your-app-name.YYYY-MM-DD.private-key.pem | tr -d '\n'
```
Paste the resulting single-line string as the value of `GITHUB_APP_PRIVATE_KEY` in your `.env` file.
The application auto-detects base64 and decodes it at startup.
=== "Coolify / platforms with multiline support"
If your deployment platform supports multi-line environment variables (Coolify does),
paste the **raw PEM content** directly -- no base64 encoding needed:
```
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
...
-----END RSA PRIVATE KEY-----
```
The application accepts both raw PEM and base64-encoded PEM.
git clone https://github.com/mariuspruvot/helprs.git
cd helprs
cp .env.example .envEdit .env and fill in every value:
# --- Database ---
# For local dev, the defaults work. For production, use strong credentials.
DATABASE_URL=postgresql+asyncpg://helprs:helprs@db:5432/helprs
# --- Secrets ---
# JWT signing key -- keep it secret, keep it safe
SECRET_KEY= # python -c "import secrets; print(secrets.token_urlsafe(48))"
# Encryption key for stored credentials (see "Rotating the encryption key" below)
FERNET_KEY= # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Admin panel password (required when ENVIRONMENT=production)
ADMIN_PASSWORD= # Choose a strong password
# --- GitHub App ---
GITHUB_APP_ID= # Numeric ID from step 1
GITHUB_CLIENT_ID= # From "OAuth credentials" section
GITHUB_CLIENT_SECRET= # From "OAuth credentials" section
GITHUB_WEBHOOK_SECRET= # The secret you generated for the webhook URL
# Private key -- base64-encoded PEM for .env, or raw PEM on platforms that support it
GITHUB_APP_PRIVATE_KEY=
# --- URLs ---
# Where your frontend is accessible (used in PR comments and CORS)
APP_BASE_URL=https://yourdomain.com
CORS_ORIGINS=["https://yourdomain.com"]
# API URL as seen by the frontend (Vite build-time variable)
# Subdomain setup: https://api.yourdomain.com
# Path-based routing: https://yourdomain.com
VITE_API_URL=https://api.yourdomain.com
# GitHub App slug (the URL-friendly app name, used in installation links)
VITE_GITHUB_APP_SLUG=helprs
# --- Environment ---
ENVIRONMENT=production # Enforces non-empty secrets and disables admin auto-auth
# --- Container ---
CONTAINER_TTL_SECONDS=900 # Max container lifetime (15 min default)
UVICORN_WORKERS=4 # API worker count
# --- Docker ---
# Absolute path to skills/ on the Docker host (for volume mounts into containers)
SKILLS_HOST_PATH=/absolute/path/to/helprs/skills
# Docker group ID on the host (for socket permissions)
DOCKER_GID=994 # Run: getent group docker | cut -d: -f3
# --- Postgres (production compose only) ---
POSTGRES_PASSWORD= # Strong password for production DB!!! warning "VITE_API_URL and VITE_GITHUB_APP_SLUG are build-time variables"
These are baked into the React bundle at build time. Changing them requires rebuilding
the web container -- setting them at runtime has no effect.
# Build and start all services. The claude-runner image is built as a side-
# effect and left available on the host for the API to spawn containers from.
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
# Verify services are healthy
docker compose -f infra/coolify/docker-compose.prod.yml ps!!! note "About the claude-runner "Exited" container"
The claude-runner service is a build-only service: docker compose up
builds the image, starts the container, which exits immediately (its entrypoint
is overridden to /bin/true) with restart: "no". The container stays in
Exited (0) state and is not restarted — but the image claude-runner:latest
remains available on the host. The API spawns new containers from this image
per session via the Docker socket.
This pattern keeps `docker compose up --build` as the single source of truth
for the whole stack, including the runner image.
The API runs on port 8000, the frontend on port 80. You need a reverse proxy (nginx, Caddy, Traefik) in front to handle TLS and route traffic.
Reverse proxy requirements:
- Subdomain routing (recommended): route
api.yourdomain.comto API (port 8000),yourdomain.comto web (port 80) - Path-based routing (alternative): route
/api/v1/*to API, everything else to web - Disable response buffering for SSE: set
X-Accel-Buffering: noor equivalent. The API already sends this header, but your reverse proxy must not override it. - Terminate TLS at the proxy -- neither the API nor web containers handle HTTPS.
See the Coolify deployment guide for step-by-step instructions.
The production compose file works on any machine with Docker:
# On your server
git clone https://github.com/mariuspruvot/helprs.git
cd helprs
# Configure environment
cp .env.example .env
# Edit .env with your values...
# Start services (the claude-runner image is built as a build-only service)
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
# Set up your preferred reverse proxy (Caddy example)
# Caddyfile:
# yourdomain.com {
# reverse_proxy localhost:80
# }
# api.yourdomain.com {
# reverse_proxy localhost:8000
# }- Go to your GitHub App settings page
- Click Install App in the sidebar
- Select your organization or personal account
- Choose which repositories to grant access to (or all)
- Click Install
After installation, GitHub sends an installation.created webhook:
- Go to your GitHub App settings > Advanced > Recent Deliveries
- You should see a successful delivery (green checkmark)
- If it failed, check:
- Is the webhook URL correct and accessible from the internet?
- Does the webhook secret match between GitHub and your
.env? - API logs:
docker compose -f infra/coolify/docker-compose.prod.yml logs api
helPRs uses BYOK -- each installation stores its own Claude credentials, encrypted at rest. Two credential types are supported:
On your local machine (not the server):
# Install Claude Code CLI if you haven't
npm install -g @anthropic-ai/claude-code
# Generate an OAuth token
claude setup-tokenThis opens a browser for OAuth authentication and outputs a token. Copy it.
!!! warning "OAuth tokens must be single-line"
The token from claude setup-token should be a single continuous string with no
whitespace or newlines. If you copy it from a terminal and it wraps, make sure
no line breaks are included when pasting into the dashboard.
If you prefer to use a standard Anthropic API key (sk-ant-...), you can enter it instead.
API key usage is billed to your Anthropic account.
- Open your helPRs instance in a browser
- Log in with GitHub (you must be an org member or the app installer)
- Go to Installations and select your installation
- In the settings, add your Claude credential (OAuth token or API key) under the BYOK section
The credential is Fernet-encrypted before storage. It's injected into containers as an ephemeral environment variable and never persisted in containers.
For direct database access, use the admin panel at /admin:
- Development: auto-authenticated (any password works)
- Production: requires the
ADMIN_PASSWORDfrom your.env
- Open a pull request on a repository where the GitHub App is installed
- helPRs receives the webhook and creates a session
- Navigate to your helPRs instance -- you'll see the installation and the PR session
- Select the challenge-me skill to start a Socratic comprehension quiz
- Answer the questions -- you'll see results streamed in real time
- After completion, a score card is displayed
If you enabled Post results to PR in installation settings, the score card is also posted as a PR comment.
FERNET_KEY encrypts the GitHub and Claude credentials stored in your
database. Replacing it naively would make every one of them unreadable, so
rotation happens in four steps and needs no downtime.
1. Generate a key and put the old one behind it.
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"FERNET_KEY=<the new key>
FERNET_KEY_FALLBACKS=["<the previous key>"]2. Deploy. New credentials are written with the new key; existing ones are still read with the old one.
3. Re-encrypt what is already stored.
docker exec helprs-api-1 uv run python -m helprs.scripts.rotate_credentialsIt reports how many credentials it rewrote. If any row cannot be read by any
configured key it names them and exits non-zero, leaving them untouched — add
the missing key to FERNET_KEY_FALLBACKS and run it again. The script is safe
to re-run.
4. Empty FERNET_KEY_FALLBACKS and deploy again. Only now is the old key
genuinely retired; until then it still decrypts real credentials and must be
protected exactly like the live one.
Rotate whenever the key may have been exposed — a leaked
.env, a departing operator, an unexplained access. RotatingFERNET_KEYdoes not invalidate user sessions; that isSECRET_KEY, which is separate.
- Verify the webhook URL is publicly accessible:
curl -I https://api.yourdomain.com/api/v1/webhooks/github - Check GitHub App > Advanced > Recent Deliveries for error details
- Check API logs:
docker compose logs api | grep webhook - Ensure the webhook secret matches between GitHub and
.env
- Verify Docker socket is mounted: check that
/var/run/docker.sockis accessible to the API container - Verify the claude-runner image exists:
docker images | grep claude-runner— if missing, rundocker compose up --build(the image is built as part of the normal compose up) - Check
SKILLS_HOST_PATHis an absolute path and the directory exists on the Docker host - Verify the
DOCKER_GIDmatches the host's Docker group:getent group docker | cut -d: -f3 - Check API logs for container creation errors:
docker compose logs api | grep container
- Your reverse proxy must not buffer SSE responses. The API sets
X-Accel-Buffering: nobut some proxies ignore this header. - For nginx: add
proxy_buffering off;to the API location block - For Cloudflare: disable response buffering or use "streaming" mode
- Test raw SSE:
curl -N -H "Authorization: Bearer <token>" https://api.yourdomain.com/api/v1/containers/sessions/<id>/stream
- Verify
CORS_ORIGINSin.envincludes your frontend URL (as a JSON array) - CORS errors can also mask 500 errors -- check API logs for unhandled exceptions
- After an API rebuild (
docker compose up --build api), re-authenticate --SECRET_KEYregeneration invalidates all JWTs
- The claude-runner container needs internet access to clone repos and fetch PR metadata
- If running behind a corporate firewall, ensure containers can reach
github.comandapi.github.com - Check the GitHub token is valid:
docker exec <container> gh auth status
- Missing database:
docker exec helprs-db-1 psql -U helprs -c "\l"-- verifyhelprsdatabase exists - Migration drift: compare
docker exec helprs-api-1 uv run alembic currentwithalembic heads. If they differ, run migrations:docker compose exec api uv run alembic upgrade head - Missing columns cause 500s that surface as CORS errors in the browser (because the error response lacks CORS headers)
# All services
docker compose -f infra/coolify/docker-compose.prod.yml logs -f
# API only
docker compose -f infra/coolify/docker-compose.prod.yml logs -f api
# A specific claude-runner container
docker logs <container-id>
# List running claude-runner containers
docker ps --filter "ancestor=claude-runner:latest"