- Rust 49.1%
- Python 33.9%
- HTML 13.5%
- JavaScript 2.9%
- Dockerfile 0.4%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| drizzle | ||
| migrations | ||
| rust | ||
| scripts | ||
| static | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| DESIGN.md | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dev | ||
| LICENSE | ||
| NOTICE | ||
| README.md | ||
Relay
A self-hosted reverse proxy with a compact administration dashboard. Relay routes your applications, controls access to each environment, and manages HTTPS certificates using Certbot and Cloudflare DNS challenges.
Built with Rust, Axum, SQLite, Redis, and Alpine.js. The dashboard has light and dark themes, live updates, and no frontend build step.
The dashboard follows DESIGN.md, with Cal Sans headings, Inter UI text, a neutral light/dark palette, and popup dialogs for application, person, invitation, zone, and token forms. Fonts are served locally under their SIL Open Font Licenses (Cal Sans, Inter).
Screenshots
Administrator interface with entirely synthetic demo data. The images contain no real accounts, routing configuration, credentials, or certificates.
More administrator screenshots: Dark theme, Application dialog, People, Invitations, Invitation dialog, Domains, Tokens, and Token dialog.
To reproduce the screenshots, run python3 docs/readme-demo.py and open http://127.0.0.1:3012 at a 1440 × 1000 viewport. This loopback demo serves the real administrator interface with synthetic fixtures. It never connects to a Relay deployment, database, or Certbot, and rejects all write requests. Use the sidebar and Edit/Add buttons to open each view or dialog; add ?theme=dark for the dark theme.
Features
- Production, beta, and development environments with independent domains, upstreams, settings, and subroutes.
- Public pass-through, member access, and administrator-only access per environment.
- Individual environment grants, invitation links, and administrator/member dashboards.
- HTTP/1.1, HTTP/2, WebSocket forwarding, and path rewriting.
- Streaming uploads/downloads with a 30-second inactivity limit and a host-local agent configuration API.
- Named Cloudflare tokens linked to DNS zones and selected automatically for matching hostnames.
- Background certificate issuance, existing wildcard certificate coverage, and automatic renewal.
- Persistent SQLite configuration, Redis sessions, and certificate/credential volumes.
- Migration support for the previous Node implementation and selected Nginx Proxy Manager configurations.
Quick start
For an existing PostgreSQL installation, follow the SQLite migration procedure before starting the updated app.
Requirements: Docker with Docker Compose, available ports 80 and 443, and a DNS name pointing to your server for HTTPS.
cp .env.example .env
Edit .env and set a unique REDIS_PASSWORD value. For a network deployment, set APP_BASE_URL to your portal URL, initially using HTTP, such as http://proxy.example.com. Set SESSION_COOKIE_DOMAIN=.example.com if you want shared login across that domain's apps.
docker compose up -d --build
Open http://localhost:10102 for local setup, or your configured portal URL. Create the first administrator account, then add applications and invite members.
The default Compose project name is reverse-proxy, retained for compatibility. The application binary also retains that name. The user-facing app is Relay.
Applications and environments
Configure each application once, with up to three environments:
| Environment | Example domain | Example upstream |
|---|---|---|
| Production | app.example.com |
http://app:3000 |
| Beta | app.beta.example.com |
http://app-beta:3000 |
| Development | app.dev.example.com |
http://app-dev:3000 |
Upstreams must be reachable from the Relay container. Compose service names work when the upstream shares a Docker network with Relay; otherwise use a reachable hostname or IP address. localhost inside the container refers to Relay itself.
Each environment has its own enable switch, access mode, WebSocket setting, Host-header setting, and HTTPS redirect. Disabled production can omit its domain and upstream, so beta-only and dev-only apps are supported. Configured hostnames are reserved uniquely, including disabled environments.
Subroutes belong to an environment. A route such as /api can use a separate upstream and target path. The longest prefix wins at a path boundary: /api matches /api/users, but not /apiculture. Unmatched requests use the environment's root upstream. Queries, request bodies, upstream response codes, and configured upstream base paths are preserved.
Request and response bodies stream without buffering the entire file. Establishing an upstream connection has a 30-second limit. Upload progress resets the 30-second wait for response headers; downloads allow 30 seconds between body reads. Active transfers can run indefinitely. A stalled upload or silent upstream returns HTTP 504 before response headers; a stalled response body closes the stream because its status has already been sent. Once an upload finishes, an upstream that sends no response for 30 seconds also times out. WebSocket handshakes retain a 30-second limit; established WebSockets have no transfer deadline.
The upload timer observes nonempty chunks as the HTTP client consumes them, so upstream backpressure eventually stops resetting it. Response timers start when the downstream requests another chunk, allowing normal downstream backpressure.
Access and dashboards
- Public: passes through without a Relay account and preserves the application's own Authorization headers. Warning: the app must authenticate these itself; they are not trusted Relay identity. Client
X-Auth-User-Id,X-Auth-Email,X-Auth-Username, andX-Auth-Roleheaders are stripped. - Public with members: anyone can visit. Set
environmentSettings.<environment>.publicWithMembers: truewithauthRequired: falseandadminOnly: false. Relay strips incoming Authorization andX-Auth-*identity headers. A valid session for any enabled Relay member or administrator supplies the same trusted identity headers as Members; anonymous, expired, and disabled sessions supply none. API bearer tokens do not establish identity in this mode. No environment grant is needed. - Members: requires Relay sign-in and an environment-specific grant. Administrators have access automatically.
- Admins: requires an administrator account.
People and invitations use inline Prod/Beta/Dev checkboxes. Public and administrator-only environments do not need member grants. Grants also cover that environment's subroutes.
Each environment has a List publicly switch for Public and Public with members access. It defaults to on, including existing environments. Set environmentSettings.<environment>.publicListed: false to hide an environment from the anonymous directory while keeping its URL publicly accessible and visible in signed-in dashboards. This controls discovery, not access.
Signed-out dashboard visitors see enabled, publicly listed Public and Public with members environments with a Sign in button when any are available. The anonymous GET /api/public directory exposes names, descriptions, public domains, access labels, and links; it excludes protected/disabled environments, upstream addresses, subroutes, and administrative data. Explicit /login, invitations, setup, and app-specific /_proxy/login still show authentication forms.
Administrators use /admin/applications, /admin/people, /admin/invitations, and /admin/domains. Members use /apps. Dashboards sync through Server-Sent Events; server-side changes are checked every two seconds, while open form drafts are retained.
Protected upstreams and signed-in visitors to Public with members receive X-Auth-User-Id, X-Auth-Email, X-Auth-Username, X-Auth-Role, and Authorization: Bearer proxy-session:<user-id>. These headers describe the authenticated Relay user; upstreams that trust them should restrict direct access to their service. Relay removes its own session cookie before forwarding. Applications with independent authentication can still require their own login.
Shared sessions work within the configured cookie domain. Hosts outside that domain receive a cookie scoped to the current hostname and require separate Relay sign-in at /_proxy/login. Logout clears the cookie using the same scope.
Local agent configuration API
Compose exposes an HTTP API through the Unix socket run/internal/api.sock on the deployment host. It has no TCP listener, DNS route, or published port. Agents can use it without a bearer token:
curl --unix-socket "$PWD/run/internal/api.sock" http://localhost/api/admin
The socket grants administrative access to local accounts that can reach it. Compose uses mode 666 so host agents can connect despite the container's different UID; the container owns the parent directory with mode 755. Do not share or mount this directory into application containers. Native runs enable the API by setting INTERNAL_API_SOCKET to an absolute path in an existing directory; their socket defaults to mode 600. Set INTERNAL_API_SOCKET_MODE (octal) to adjust access. Leave INTERNAL_API_SOCKET empty to disable it. Restart safely replaces a stale socket and refuses to replace an active socket or a regular file.
| Method | Endpoint | Operation |
|---|---|---|
| GET | /api/health |
Health check |
| GET | /api/admin |
Current routes, users, invitations, and administration state |
| POST | /api/admin/websites |
Create an application and its environment routes |
| PUT / DELETE | /api/admin/websites/{id} |
Replace / delete an application |
| PUT / DELETE | /api/admin/users/{id} |
Update / delete a user |
| POST | /api/admin/invites |
Invite a new user |
| DELETE | /api/admin/invites/{id} |
Delete an invitation |
Use the same JSON payloads as the dashboard. Updates replace configuration: fetch the current state, preserve fields you are keeping, edit the desired fields, then send the complete object. An application includes name, domain, upstreamUrl, enabled, authRequired, environmentSettings, and paths; subroutes include environment, sourcePath, upstreamUrl, and targetPath. User updates require email, username, role (admin or user), enabled, and environmentAccess ([{"websiteId":"...","environment":"production"}]). New users accept invitations and choose their own passwords.
For example, save a modified application object as /tmp/relay-app.json, then apply it:
curl --unix-socket "$PWD/run/internal/api.sock" \
-X PUT http://localhost/api/admin/websites/APP_ID \
-H 'Content-Type: application/json' --data-binary @/tmp/relay-app.json
Validation, access cleanup, database transactions, and last-administrator protection match the dashboard. Certificate issuance and credential mutation endpoints are excluded from this API. Public HTTP/HTTPS administration endpoints still require their existing session or API_AUTH_TOKEN; request headers cannot enable the socket's administrative identity.
HTTPS and Cloudflare tokens
- In Domains → Tokens, create a named Cloudflare API token.
- In Domains → Zones, add a root domain, certificate email, and link the token.
- In Applications, select SSL beside the environment's hostname.
The most specific matching zone assignment supplies the token. A token can serve several zones, and rotation updates their credential files. Linked tokens cannot be deleted until their zones are reassigned.
Use a token with Zone → DNS → Edit permissions for the appropriate zones. See the Certbot Cloudflare plugin documentation for the required permissions and DNS challenge behavior. Application DNS records must point to Relay; Certbot only manages ACME challenge TXT records.
Certificate jobs run in the background. Relay serves HTTPS directly using SNI and reloads certificates after issuance and renewal. Exact and single-label wildcard certificates are recognized by the dashboard. Certbot checks renewal at startup and every 12 hours. The Cloudflare wrapper safely reuses an existing challenge only when both its name and validation value match.
To obtain a certificate for the portal itself, save the portal's matching zone token, then send an authenticated POST /api/admin/portal-certificate. For example, set a strong API_AUTH_TOKEN in .env, recreate the app container, and request it with that token:
curl -X POST http://proxy.example.com/api/admin/portal-certificate \
-H 'Authorization: Bearer YOUR_API_AUTH_TOKEN' \
-H 'Content-Type: application/json' -d '{}'
After issuance succeeds, change APP_BASE_URL to https://proxy.example.com and recreate the app container so session cookies are marked Secure:
docker compose up -d --no-deps app
For testing, CERTBOT_STAGING=true requests untrusted staging certificates. To obtain trusted certificates afterward, set it to false, recreate the app, and request SSL again.
Configuration and persistence
| Setting | Purpose |
|---|---|
APP_BASE_URL |
Portal URL and control hostname |
DATABASE_URL |
SQLite URL; native default sqlite://relay.sqlite3, Compose /var/lib/relay/relay.sqlite3 |
REDIS_PASSWORD |
Compose session-store password |
SESSION_COOKIE_DOMAIN |
Optional parent domain for shared sessions |
API_AUTH_TOKEN |
Optional administrator bearer token on HTTP/HTTPS; empty disables it |
INTERNAL_API_SOCKET |
Absolute Unix socket path; empty disables the local API (Compose sets it) |
INTERNAL_API_SOCKET_MODE |
Octal socket permissions; native default 600, Compose 666 |
HTTP_PORT, HTTPS_PORT |
Published HTTP/HTTPS ports, default 80/443 |
CERTBOT_STAGING |
Use the ACME staging service for testing |
Compose also publishes the management port at 10102 and the Redis port at 10101. Adjust their bindings for your network. Environment variables are documented in .env.example; Docker settings are in docker-compose.yml.
Keep the sqlite, redis, certificates, and dns-credentials volumes. Retain the legacy postgres volume after migration for recovery. Certificates, ACME accounts, and renewal configurations live in certificates; Cloudflare secrets and Certbot logs live in dns-credentials. Token secrets are stored in private files and are never returned through the dashboard API. SQLite stores token metadata and zone associations.
Before upgrading, back up the database and preserve the other volumes:
umask 077
docker compose exec -T app python3 -c 'import sqlite3; src=sqlite3.connect("/var/lib/relay/relay.sqlite3"); dst=sqlite3.connect("/var/lib/relay/backup.sqlite3"); src.backup(dst); dst.close(); src.close()'
docker compose cp app:/var/lib/relay/backup.sqlite3 ../relay-backup.sqlite3
chmod 600 ../relay-backup.sqlite3
Store backups outside the repository. SQLite backups must use the backup API or include the WAL while the service is stopped. Avoid docker compose down -v unless you intend to delete persistent data.
Certbot logs are at /var/lib/reverse-proxy/logs in the app container. To verify DNS renewal without replacing live certificates:
docker compose exec --user 10001 app certbot renew --dry-run \
--no-random-sleep-on-renew --config-dir /etc/letsencrypt \
--work-dir /var/lib/reverse-proxy/certbot \
--logs-dir /var/lib/reverse-proxy/logs
PostgreSQL to SQLite migration
Relay now stores accounts, invitations, applications, environment permissions, routing, and certificate/token metadata in SQLite. Compose keeps the database at /var/lib/relay/relay.sqlite3 in the sqlite volume. Redis continues to store login sessions, and certificates and DNS secrets remain in their existing volumes.
SQLite uses write-ahead logging (WAL), foreign-key enforcement, and immediate write transactions. Concurrent invitation redemptions and administrator updates retain their existing safeguards. JSON settings are stored as text, booleans as integers, and timestamps as text; API responses retain their existing types. Transactional revision triggers keep dashboard live updates working across database connections.
Startup applies migrations/bootstrap.sql transactionally. It creates a new SQLite database when none exists; it does not automatically import PostgreSQL. Existing PostgreSQL installations must complete the one-time copy below before starting the updated app. Historical PostgreSQL/Drizzle schemas remain available for migration regression tests.
Copy an existing PostgreSQL database
Build the new app first, then stop the old app so no configuration writes occur during the copy. Preserve its image/configuration if rollback is needed, and save a private PostgreSQL backup outside the checkout. For older schemas, apply migrations/postgres-bootstrap.sql to PostgreSQL inside a transaction before exporting.
scripts/migrate_sqlite.py reads all application tables in one consistent PostgreSQL snapshot. It creates a private SQLite file, refuses to overwrite an existing destination, and verifies every copied value, row counts, foreign keys, and database integrity. Failed imports remove the incomplete destination. User and route IDs, password hashes, invitations, and environment grants are preserved without resetting accounts.
The example uses the default Compose container and database names; adjust them for a differently named deployment:
docker compose build app
docker compose stop app
umask 077
# Preserve this backup outside the checkout in a private directory.
docker exec reverse-proxy-postgres-1 pg_dump -U reverse-proxy -d reverse-proxy > ../relay-postgres-backup.sql
python3 scripts/migrate_sqlite.py --postgres-container reverse-proxy-postgres-1 --output ../relay.sqlite3
docker compose run --rm --no-deps --entrypoint sh \
-v "$(realpath ../relay.sqlite3):/tmp/relay.sqlite3:ro" app \
-c 'test ! -e /var/lib/relay/relay.sqlite3 && cp /tmp/relay.sqlite3 /var/lib/relay/relay.sqlite3 && chmod 600 /var/lib/relay/relay.sqlite3'
docker compose up -d --build --no-deps app
Keep the Redis, certificate, and credential volumes unchanged so existing sessions and TLS remain valid. Verify the socket and management health endpoints, the portal hostname, saved applications and permissions, and proxied traffic before retiring PostgreSQL:
curl --fail --unix-socket "$PWD/run/internal/api.sock" http://localhost/api/health
curl --fail http://127.0.0.1:10102/api/health
docker compose ps
docker compose logs --tail=50 app
Once verified, stop and optionally remove the old PostgreSQL container. Its volume stays intact; the legacy-postgres Compose profile can recreate the service if needed. Retain migration backups and the old app image/configuration for as long as rollback is required. Recovery uses that previous app against PostgreSQL; changes made in SQLite after cutover are not automatically copied back. Remove any corresponding dev containers and Relay dev route after successful deployment, preserving persistent volumes.
NPM migration
The NPM importer works from a private SQLite and Let's Encrypt snapshot; --relay-db selects the target Relay SQLite file. Stop Relay during import:
python3 scripts/import_npm.py --help
Test on a staging database first. The importer handles supported HTTP proxy hosts, certificate lineages, Cloudflare credentials, and portal ingress. It rejects unsupported access lists, advanced directives, and other unsupported NPM records. Use --proxy-upstream-host and --proxy-upstream-port to identify NPM hosts that forwarded to your old proxy. Preserve source backups and verify imported renewals before switching traffic.
Development and tests
Start an isolated development preview with automatic rebuilds on changes to Rust, migrations, Cargo files, or static assets:
DEV_APP_BASE_URL=http://relay-dev.example.com \
docker compose -f docker-compose.dev.yml up -d --build
This runs cargo run --locked in a separate Compose project with private SQLite/Redis data, target and registry volumes, and the relay-sqlite-development network alias. Set DEV_APP_BASE_URL to your dev URL (for example, http://relay-dev.example.com) and configure that hostname as an administrator-only Relay dev route to http://relay-sqlite-development:10102, preserving the Host header. The dev instance uses independent accounts and sessions; initialize it through its setup screen. No production socket, secrets, certificates, or database are mounted.
Use a Rust toolchain supporting edition 2024. Native development requires Redis. Optionally provide a SQLite DATABASE_URL, plus REDIS_URL; the database is created automatically.
PORT=8080 APP_BASE_URL=http://localhost:8080 TLS_ENABLED=false \
DNS_CREDENTIALS_DIR=/tmp/relay/dns CERTBOT_CONFIG_DIR=/tmp/relay/certs \
cargo run
For real certificate operations, use the Docker image, which installs Certbot and its Cloudflare wrapper and provisions writable directories. Alpine.js is vendored; no Node build is required.
cargo fmt --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo build --locked
python3 tests/test_certbot_runner.py
python3 tests/integration.py
python3 tests/test_sqlite_migration.py
node tests/test_public_portal.js
docker compose config --quiet
Integration tests require Docker, Python 3, OpenSSL, and curl with HTTP/2 support. They migrate a disposable legacy PostgreSQL database to a private SQLite file and create a disposable Redis container. They use a fake Certbot that generates local certificates. They cover migration compatibility, independent environments, permissions, invitations, stale cookies, routing, active and stalled streaming transfers, the local configuration API, WebSockets, token management, TLS, and restart persistence without contacting Cloudflare or Let's Encrypt.
Contributions are welcome. Include reproduction steps for bugs, keep changes focused, and run the checks relevant to your changes. Do not include credentials, certificate keys, database dumps, or real deployment data in issues or pull requests.
Install on your phone
Open the main Relay dashboard over HTTPS. On Android, use Chrome’s menu and choose Install app or Add to Home screen. On iPhone, open it in Safari, tap Share, then Add to Home Screen and enable Open as Web App if offered. Relay opens in its own window with its app icon and uses your existing sign-in session.
The installed app requires a connection for dashboard operations. If a page cannot load, it shows a retry page; administrative data and sessions are never stored in the service worker cache. Installation is available on Relay portal hostnames, not on proxied application login pages.
License
Relay is licensed under the Apache License 2.0. Vendored Alpine.js retains its MIT license. See NOTICE for attribution.
