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
/chatsand/ipod/:id - docs clean URLs like
/docs/overview - health checks at
/healthand/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:
PORTorAPI_PORT— listening port (defaults to3000)
API_HOST— bind host (defaults to0.0.0.0)APP_PUBLIC_ORIGIN— canonical browser origin, e.g.https://your-domain.comAPI_ALLOWED_ORIGINS— comma-separated allowed browser origins for/api/*; supports wildcard subdomain patterns (e.g.*.example.com), and*to allow all originsSTORAGE_PROVIDER— optional explicit storage backend (s3); auto-detected if unsetTRUSTED_PROXY_COUNT— set to1when behind one trusted reverse proxy (nginx/Caddy/Coolify) so per-IP rate limits useX-Forwarded-ForcorrectlyAUTH_COOKIE_SECURE— set to1/trueon HTTPS self-hosts if the auth cookie Secure flag is wrongIP_GEOLOCATION_URL_TEMPLATE— optional override for the IP-geolocation provider (defaults tohttps://ipwho.is/{ip}); use{ip}as the placeholderIP_GEOLOCATION_DISABLED— set to1/trueto 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 viaPORT) - 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:
- In Coolify → your app → Advanced → enable Include Source Commit in Build
- The build script reads
SOURCE_COMMIT(Coolify) orGIT_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:
- On pull requests, CI runs
bun run test:unitandbun run build.
- On pushes to
main, CI builds/pushes the GHCR image (which runsbun run buildin Docker) and triggers the Coolify deploy webhook.
Configure COOLIFY_WEBHOOK_URL and COOLIFY_TOKEN in GitHub Actions secrets.
To wire it up:
- 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.
- In GitHub, open Settings → Secrets and variables → Actions → New repository secret and add:
COOLIFY_WEBHOOK_URL— the deploy webhook URLCOOLIFY_TOKEN— the Coolify API token (sent asAuthorization: Bearer ...)
- Optional: under Variables, set
COOLIFY_APP_URLto your public app URL so it shows up on the GitHub deployment timeline. - 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.