Pangolin setup
Set up Rybbit with Pangolin as a self-hosted reverse proxy using Newt
This guide shows you how to set up Rybbit with Pangolin, a self-hosted reverse proxy, using a Newt container inside the Docker Compose stack. This approach avoids exposing ports directly on your host network.
Prerequisites
- Pangolin already running and accessible (e.g.
https://pangolin.yourdomain.com) - A Newt ID and Newt Secret from your Pangolin instance
- A domain name pointed to your Pangolin server (e.g.
tracking.yourdomain.com) - Docker and Docker Compose installed
You must generate a proper BETTER_AUTH_SECRET before starting. Without a valid 32+ character secret, you will not be able to create organizations or log in. Generate one with: openssl rand -hex 32
Setup Steps
Configure Environment Variables
Create a .env file in your Rybbit directory:
# Required: Your domain and base URL
DOMAIN_NAME=tracking.yourdomain.com
BASE_URL=https://tracking.yourdomain.com
# Required: Authentication secret (must be 32+ characters)
# Generate with: openssl rand -hex 32
BETTER_AUTH_SECRET=your_generated_secret_here
# Required: Disable built-in webserver since Pangolin handles routing
USE_WEBSERVER=false
# Database passwords: replace each with your own strong password
CLICKHOUSE_DB=analytics
CLICKHOUSE_PASSWORD=your_clickhouse_password
POSTGRES_DB=analytics
POSTGRES_USER=rybbit
POSTGRES_PASSWORD=your_postgres_password
REDIS_PASSWORD=your_redis_password
# Host port mappings in host_ip:host_port:container_port format
HOST_BACKEND_PORT=127.0.0.1:3001:3001
HOST_CLIENT_PORT=127.0.0.1:3002:3002
# Optional: Disable new user signups after creating admin account
# DISABLE_SIGNUP=true
# Optional: Mapbox token for globe visualizations
# MAPBOX_TOKEN=your_mapbox_tokenSet the passwords before the first start: Postgres only reads POSTGRES_USER and POSTGRES_PASSWORD when it creates its data volume. If you reuse an existing postgres-data volume, keep the user and password it was created with.
Newt reaches the backend and client over the Docker network, so the host port mappings are only for access from the server itself. Binding them to 127.0.0.1 keeps them off the network. If ports 3001 or 3002 are already taken on your host, change the host port, e.g. 127.0.0.1:4001:3001. A value without an IP, such as 3001:3001, publishes the port on all interfaces.
Create Docker Compose File
Create a docker-compose.yml file in the same directory. This includes a Newt container that connects Pangolin to your Rybbit services:
services:
newt:
image: fosrl/newt
container_name: newt-rybbit
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://pangolin.yourdomain.com
- NEWT_ID=your_newt_id
- NEWT_SECRET=your_newt_secret
clickhouse:
container_name: clickhouse
image: clickhouse/clickhouse-server:26.3.62.3
volumes:
- clickhouse-data:/var/lib/clickhouse
configs:
- source: clickhouse_network
target: /etc/clickhouse-server/config.d/network.xml
- source: clickhouse_logging
target: /etc/clickhouse-server/config.d/logging_rules.xml
- source: clickhouse_resource_limits
target: /etc/clickhouse-server/config.d/resource_limits.xml
# Profile (user-level) settings are only read from users.d, not config.d
- source: clickhouse_user_settings
target: /etc/clickhouse-server/users.d/user_settings.xml
environment:
- CLICKHOUSE_DB=${CLICKHOUSE_DB:-analytics}
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-default}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-frog}
# Lets the backend provision the least-privilege `rybbit_query` user
# (custom SQL queries / dashboards) with SQL at startup.
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8123/ping"]
interval: 3s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
ports:
# Localhost-only: reach from outside via SSH tunnel
- "127.0.0.1:8123:8123"
postgres:
image: postgres:17.4
container_name: postgres
environment:
- POSTGRES_USER=${POSTGRES_USER:-frog}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-frog}
- POSTGRES_DB=${POSTGRES_DB:-analytics}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 3s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
ports:
# Localhost-only: reach from outside via SSH tunnel
- "127.0.0.1:5432:5432"
redis:
image: redis:8.6.4-alpine
container_name: redis
ports:
# Localhost-only: reach from outside via SSH tunnel
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-changeme}", "--no-auth-warning", "ping"]
interval: 3s
timeout: 5s
retries: 5
start_period: 5s
restart: unless-stopped
command:
- redis-server
- --requirepass
- ${REDIS_PASSWORD:-changeme}
- --appendonly
- "yes"
- --appendfsync
- everysec
- --maxmemory-policy
- noeviction
backend:
image: ghcr.io/rybbit-io/rybbit-backend:${IMAGE_TAG:-latest}
container_name: backend
ports:
- "${HOST_BACKEND_PORT:-127.0.0.1:3001:3001}"
environment:
- NODE_ENV=production
- CLICKHOUSE_HOST=http://clickhouse:8123
- CLICKHOUSE_DB=${CLICKHOUSE_DB:-analytics}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-frog}
- CLICKHOUSE_QUERY_PASSWORD=${CLICKHOUSE_QUERY_PASSWORD:-${CLICKHOUSE_PASSWORD:-frog}}
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DB=${POSTGRES_DB:-analytics}
- POSTGRES_USER=${POSTGRES_USER:-frog}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-frog}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-changeme}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BASE_URL=${BASE_URL}
- DISABLE_SIGNUP=${DISABLE_SIGNUP}
- DISABLE_TELEMETRY=${DISABLE_TELEMETRY}
- MAPBOX_TOKEN=${MAPBOX_TOKEN}
- CLUSTER_WORKERS=${CLUSTER_WORKERS:-4}
- DASHBOARD_CACHE_TTL_SECONDS=${DASHBOARD_CACHE_TTL_SECONDS:-30}
depends_on:
clickhouse:
condition: service_healthy
postgres:
condition: service_started
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3001/api/health"]
interval: 3s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
client:
image: ghcr.io/rybbit-io/rybbit-client:${IMAGE_TAG:-latest}
container_name: client
ports:
- "${HOST_CLIENT_PORT:-127.0.0.1:3002:3002}"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_BACKEND_URL=${BASE_URL}
- NEXT_PUBLIC_DISABLE_SIGNUP=${DISABLE_SIGNUP}
depends_on:
- backend
restart: unless-stopped
volumes:
clickhouse-data:
postgres-data:
redis-data:
configs:
clickhouse_network:
content: |
<clickhouse>
<listen_host>0.0.0.0</listen_host>
</clickhouse>
clickhouse_logging:
content: |
<clickhouse>
<logger>
<level>warning</level>
<console>true</console>
</logger>
<query_thread_log remove="remove"/>
<query_log remove="remove"/>
<query_views_log remove="remove"/>
<query_metric_log remove="remove"/>
<error_log remove="remove"/>
<opentelemetry_span_log remove="remove"/>
<text_log remove="remove"/>
<trace_log remove="remove"/>
<metric_log remove="remove"/>
<asynchronous_metric_log remove="remove"/>
<session_log remove="remove"/>
<part_log remove="remove"/>
<latency_log remove="remove"/>
<processors_profile_log remove="remove"/>
</clickhouse>
clickhouse_resource_limits:
content: |
<clickhouse>
<!-- Leave headroom for the backend, Redis, Caddy, the kernel, and the
filesystem cache instead of allowing ClickHouse to exhaust RAM. -->
<max_server_memory_usage_to_ram_ratio>0.80</max_server_memory_usage_to_ram_ratio>
<!-- Share at most one query-processing thread per CPU core across all
concurrent queries. This throttles work rather than rejecting it. -->
<concurrent_threads_soft_limit_ratio_to_cores>1</concurrent_threads_soft_limit_ratio_to_cores>
<!-- Bound memory used by background merges and mutations separately. -->
<merges_mutations_memory_usage_to_ram_ratio>0.25</merges_mutations_memory_usage_to_ram_ratio>
</clickhouse>
clickhouse_user_settings:
content: |
<clickhouse>
<profiles>
<default>
<enable_json_type>1</enable_json_type>
<!-- Combine concurrent inserts into larger blocks inside ClickHouse.
Wait for the flush so the backend still receives insert errors. -->
<async_insert>1</async_insert>
<wait_for_async_insert>1</wait_for_async_insert>
<log_queries>0</log_queries>
<log_query_threads>0</log_query_threads>
<log_processors_profiles>0</log_processors_profiles>
<!-- Bound individual queries while preserving enough parallelism
for large analytics scans. Query-count limits remain unset so
ingestion is never rejected by admission control. -->
<max_memory_usage>32000000000</max_memory_usage>
<max_threads>16</max_threads>
</default>
</profiles>
</clickhouse>The newt service runs inside the same Docker network as your other containers. It connects to your Pangolin instance and routes traffic to backend and client by their container hostnames — no host port exposure needed for Pangolin to reach them.
Start Services
Start all services:
docker compose up -dVerify everything is running:
docker compose psCheck that the backend is healthy:
docker compose logs backendConfigure Pangolin
In your Pangolin dashboard, create a new site for your Rybbit domain and configure two resources:
-
Client resource — routes the root domain to the Rybbit frontend:
- Target:
client(the Docker container hostname) - Port:
3002
- Target:
-
Backend resource — routes
/apitraffic to the Rybbit backend:- Target:
backend(the Docker container hostname) - Port:
3001
Route paths starting with
/.well-known/oauth-and/.well-known/openid-configurationto the same backend target. MCP clients use them to discover OAuth login. - Target:
For both resources, add these custom headers in the Pangolin configuration:
| Header | Value |
|---|---|
X-Forwarded-Proto | https |
X-Forwarded-Host | tracking.yourdomain.com |

The X-Forwarded-Proto and X-Forwarded-Host headers are critical. Without them, API requests from the client to the backend will fail. This is the most common source of issues with this setup.
You do not need to enable "Advanced Mode" in Pangolin. The standard configuration with the correct headers is sufficient.
Access Rybbit
Navigate to https://tracking.yourdomain.com and create your admin account.
If you see 404 errors on /api routes, double-check that:
- Your
BETTER_AUTH_SECRETis set and is at least 32 characters - The
X-Forwarded-ProtoandX-Forwarded-Hostheaders are configured in Pangolin
Troubleshooting
Cannot create organizations or log in
This is almost always caused by a missing or invalid BETTER_AUTH_SECRET. Generate a proper secret:
openssl rand -hex 32Add it to your .env file and restart the services:
docker compose down && docker compose up -d404 errors on API routes
Verify that Pangolin is forwarding the correct headers. The X-Forwarded-Proto header must be https and X-Forwarded-Host must match your domain. Without these, the backend cannot properly handle requests.
Backend health check failing
Check the backend logs for errors:
docker compose logs -f backendEnsure ClickHouse and PostgreSQL are healthy:
docker compose psNewt not connecting to Pangolin
Verify your Newt credentials and Pangolin endpoint:
docker compose logs -f newtMake sure PANGOLIN_ENDPOINT, NEWT_ID, and NEWT_SECRET are correct in your docker-compose.yml.
Video Reference
For a visual walkthrough of setting up Rybbit with Pangolin, see the official Pangolin YouTube guide: