ryOS ryOS / Docs
GitHub Launch

Self-hosting ryOS on a VPS or Coolify

ryOS runs as a single Bun production server. That server handles:

  • API routes under api/
  • built frontend assets from dist/
  • SPA deep links like /chats and /ipod/:id
  • docs clean URLs like /docs/overview
  • health checks at /health and /api/health
  • runtime config bootstrap at /app-config.js
  • embed routes (/embed/infinite-mac, /embed/pc)
  • optional local websocket realtime

This makes it a good fit for:

  • a plain VPS + systemd
  • Coolify Dockerfile deployments
  • self-hosted containers behind Traefik / Nginx / Caddy


1) Prerequisites

  • Linux VPS or container host
  • Bun installed (or Docker/Coolify)
  • a public domain + TLS if exposing to the internet
  • Redis if using self-hosted Redis / local websocket fanout

2) Build + run locally in production mode

bun install
bun run build
APP_PUBLIC_ORIGIN="https://your-domain.com" \
API_ALLOWED_ORIGINS="https://your-domain.com" \
bun run start

Important runtime envs:

  • PORT or API_PORT — listening port (defaults to 3000)
  • API_HOST — bind host (defaults to 0.0.0.0)
  • APP_PUBLIC_ORIGIN — canonical browser origin, e.g. https://your-domain.com
  • API_ALLOWED_ORIGINS — comma-separated allowed browser origins for /api/*; supports wildcard subdomain patterns (e.g. *.example.com), and * to allow all origins
  • STORAGE_PROVIDER — optional explicit storage backend (s3); auto-detected if unset
  • TRUSTED_PROXY_COUNT — set to 1 when behind one trusted reverse proxy (nginx/Caddy/Coolify) so per-IP rate limits use X-Forwarded-For correctly
  • AUTH_COOKIE_SECURE — set to 1/true on HTTPS self-hosts if the auth cookie Secure flag is wrong
  • IP_GEOLOCATION_URL_TEMPLATE — optional override for the IP-geolocation provider (defaults to https://ipwho.is/{ip}); use {ip} as the placeholder
  • IP_GEOLOCATION_DISABLED — set to 1/true to disable the IP-geolocation fallback entirely (no outbound calls)


3) Redis backend options

ryOS supports two Redis modes.

Option A — Standard Redis / Valkey / self-hosted Redis

Recommended for Coolify / VPS deployments:

REDIS_URL="redis://default:password@redis:6379/0"

This mode also enables Redis pub/sub for local websocket fanout.

Option B — Upstash REST

REDIS_KV_REST_API_URL="https://..."
REDIS_KV_REST_API_TOKEN="..."

4) Optional account recovery email

Email-based account recovery uses Resend. Set both variables together; if either one is missing, ryOS falls back to Telegram-linked account recovery when available.

RESEND_API_KEY="..."
RECOVERY_EMAIL_FROM="ryOS <[email protected]>"

5) Realtime backend options

ryOS supports two realtime modes.

Option A — Pusher

REALTIME_PROVIDER="pusher"
PUSHER_APP_ID="..."
PUSHER_KEY="..."
PUSHER_SECRET="..."
PUSHER_CLUSTER="us3"

Option B — Local websocket

Recommended for self-hosted deployments:

REALTIME_PROVIDER="local"
REALTIME_WS_PATH="/ws"

Notes:

  • local websocket mode works best with REDIS_URL
  • with REDIS_URL, websocket events can fan out across instances via Redis pub/sub
  • without REDIS_URL, local websocket delivery falls back to in-process fanout only


6) Object storage backend

ryOS uses S3-compatible object storage for cloud sync and private AI chat images.

Works with MinIO, Cloudflare R2, Backblaze B2, AWS S3, or any S3-compatible provider:

STORAGE_PROVIDER=s3
S3_BUCKET=ryos-sync
S3_REGION=us-east-1
S3_ENDPOINT=https://s3.your-provider.com
S3_PUBLIC_ENDPOINT=https://s3.your-provider.com  # public-facing URL (for presigned URLs)
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_FORCE_PATH_STYLE=true   # required for MinIO and some providers
STORAGE_CLIENT_UPLOAD=proxy  # optional: route browser uploads through the API (use when bucket CORS is unavailable, e.g. local dev on Hetzner)

Bucket CORS (required for Cloud Sync)

Cloud Sync uploads and downloads use presigned URLs opened directly in the browser. The object storage bucket must allow cross-origin PUT, GET, and HEAD from your app origins. Without this, sync fails with errors like “Upload failed before an HTTP response was received”.

Apply the repo helper (reads .env.local / env vars):

bun run scripts/configure-s3-cors.ts

It allows local dev origins (http://localhost:5173, etc.), APP_PUBLIC_ORIGIN, and any explicit entries in API_ALLOWED_ORIGINS (wildcard patterns such as *.example.com are skipped — list concrete origins in S3_CORS_EXTRA_ORIGINS instead).

If presigned browser uploads still fail (some S3-compatible providers do not return CORS headers on presigned PUT responses), set STORAGE_CLIENT_UPLOAD=proxy so uploads go through /api/sync/v2/blob-upload instead of directly to object storage.

Example manual CORS rule (Hetzner, MinIO, AWS S3, R2, etc.):

<CORSConfiguration>
  <CORSRule>
    <AllowedOrigin>https://your-domain.com</AllowedOrigin>
    <AllowedOrigin>http://localhost:5173</AllowedOrigin>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <MaxAgeSeconds>3600</MaxAgeSeconds>
  </CORSRule>
</CORSConfiguration>

7) Coolify deployment

The repository includes a Dockerfile suitable for Coolify Dockerfile deployments. The image includes a health check endpoint at /health.

Suggested Coolify settings

  • Build Pack: Dockerfile
  • Port Exposes: 3000 (or whatever you set via PORT)
  • Command override: not required
  • Environment variables: set them in the Coolify UI

Recommended environment set for a fully self-hosted stack:

NODE_ENV=production
PORT=3000
API_HOST=0.0.0.0
APP_PUBLIC_ORIGIN=https://your-domain.com
API_ALLOWED_ORIGINS=https://your-domain.com
REDIS_URL=redis://default:password@redis:6379/0
REALTIME_PROVIDER=local
REALTIME_WS_PATH=/ws
STORAGE_PROVIDER=s3
S3_BUCKET=ryos-sync
S3_REGION=us-east-1
S3_ENDPOINT=https://s3.your-provider.com
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key

Coolify's reverse proxy supports WebSockets, so /ws can stay behind the normal app domain.

Version / build number (avoid "dev")

The app shows a version like 10.3 (abc1234) from version.json (independent of the npm/Electron package.json version). Without a commit SHA at build time, it falls back to dev. To show the real commit:

  1. In Coolify → your app → Advanced → enable Include Source Commit in Build
  1. The build script reads SOURCE_COMMIT (Coolify) or GIT_COMMIT_SHA (generic CI)

No extra env vars needed — Coolify injects SOURCE_COMMIT when that option is enabled.

Coolify deployments are auto-detected (via COOLIFY_* environment variables) and displayed in the Admin app's Server page.

Gating Coolify deploys behind GitHub Actions

The repository ships with a .github/workflows/build-and-deploy.yml workflow:

  1. On pull requests, CI runs bun run test:unit and bun run build.
  1. On pushes to main, CI builds/pushes the GHCR image (which runs bun run build in Docker) and triggers the Coolify deploy webhook.

Configure COOLIFY_WEBHOOK_URL and COOLIFY_TOKEN in GitHub Actions secrets.

To wire it up:

  1. In Coolify, open your application → Webhooks → copy the Deploy webhook URL (it looks like https://coolify.example.com/api/v1/deploy?uuid=<resource-uuid>) and the API token.
  1. In GitHub, open Settings → Secrets and variables → Actions → New repository secret and add:
    • COOLIFY_WEBHOOK_URL — the deploy webhook URL
    • COOLIFY_TOKEN — the Coolify API token (sent as Authorization: Bearer ...)
  2. Optional: under Variables, set COOLIFY_APP_URL to your public app URL so it shows up on the GitHub deployment timeline.
  3. Optional: in Coolify, disable the built-in Git auto-deploy so the webhook from GitHub Actions is the single source of truth.

The workflow also exposes a manual workflow_dispatch trigger with a force_deploy input that appends force=true to the webhook URL (useful when only environment variables changed).


8) VPS + systemd example

Example unit file:

[Unit]
Description=ryOS self-hosted server
After=network.target

[Service]
Type=simple
WorkingDirectory=/srv/ryos
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=API_HOST=127.0.0.1
Environment=APP_PUBLIC_ORIGIN=https://your-domain.com
Environment=API_ALLOWED_ORIGINS=https://your-domain.com
Environment=REDIS_URL=redis://127.0.0.1:6379/0
Environment=REALTIME_PROVIDER=local
Environment=REALTIME_WS_PATH=/ws
EnvironmentFile=/srv/ryos/.env.local
ExecStart=/usr/local/bin/bun run start
Restart=always
RestartSec=3
User=www-data
Group=www-data

[Install]
WantedBy=multi-user.target

9) Reverse proxy example (optional)

If you still want Nginx or Caddy in front, just proxy the single Bun server:

server {
  listen 443 ssl http2;
  server_name your-domain.com;

  location /ws {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 300s;
  }

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_read_timeout 300s;
  }
}

No separate static file server is required.


10) Notes and caveats

  • /api/sync/* endpoints use the storage adapter (api/_utils/storage.ts), which targets S3-compatible storage. See section 6 for configuration.
  • For local development:
    • bun run dev
    • or bun run dev:api + bun run dev:vite
  • API tests can target standalone mode:
    • API_URL=http://localhost:3000 bun run test:new-api

11) Troubleshooting Docker bridge networking

If you deploy a multi-container self-hosted stack with REDIS_URL and see:

  • container-to-container Redis connections timing out
  • local websocket fanout failing across instances
  • Docker DNS resolving service names correctly, but TCP never connects

then the issue is usually the host's Docker bridge / iptables setup, not ryOS itself.

This showed up in nested Docker testing when the host had mixed iptables-nft and iptables-legacy state, and bridge traffic was blocked even though containers and DNS were healthy.

Quick checks

From an app container, verify Redis TCP directly:

docker exec <app-container> node -e "const net=require('net'); const s=net.createConnection(6379,'redis'); s.on('connect',()=>{console.log('connected'); s.destroy(); process.exit(0)}); s.on('error',e=>{console.error(e.message); process.exit(1)}); setTimeout(()=>{console.error('timeout'); process.exit(2)},3000);"

If that times out, inspect both iptables frontends:

update-alternatives --display iptables
sudo iptables -S FORWARD
sudo iptables-legacy -S FORWARD
sudo iptables-nft -S FORWARD

Known fix for mixed iptables backend hosts

If Docker bridge traffic is being blocked by legacy forwarding rules, this restores bridge connectivity immediately:

sudo iptables-legacy -P FORWARD ACCEPT

After applying it, re-test Redis TCP between containers before debugging ryOS.

Important note

This is a host-level Docker networking issue. If you see the timeout only on a specific VM, VPS image, or nested-container environment, fix the Docker / iptables configuration there rather than changing ryOS application code.