A reusable runbook for self-hosting Convex directly from the precompiled binary, without Docker, Podman, Nginx, or Caddy.
Tested workflow: Linux, SQLite, multiple independent Convex processes, Tailscale for administration, and Cloudflare Tunnel for public HTTPS.
Last verified: August 2026.
What this setup is
Convex can be self-hosted from a precompiled native binary. Docker is optional.
For a small deployment, the stack can be reduced to:
Development machine
├── convex-local-backend
├── Node.js + Convex CLI
└── frontend dev server
Production VM
├── convex-local-backend
├── tailscaled
├── cloudflared
└── systemd
No Docker
No Podman
No Nginx
No Caddy
No public SSH port
No public Convex ports
The Convex backend uses SQLite by default and stores its database in the process's current working directory, not beside the executable.
The dashboard is optional. It is an administrative UI and does not need to run for the application to work.
1. Download the correct Convex backend binary
Convex publishes precompiled convex-local-backend binaries in GitHub Releases.
For x86-64 Linux, use:
convex-local-backend-x86_64-unknown-linux-gnu.zip
For ARM64 Linux, use:
convex-local-backend-aarch64-unknown-linux-gnu.zip
Architecture matters. An x86-64 binary will not normally run on an ARM64 machine.
Unzip it:
unzip convex-local-backend-x86_64-unknown-linux-gnu.zip
chmod +x convex-local-backend
The ZIP contains one large executable:
convex-local-backend
A convenient per-user installation location is:
mkdir -p ~/.local/bin
mv convex-local-backend ~/.local/bin/
chmod +x ~/.local/bin/convex-local-backend
Verify:
convex-local-backend --help
The binary does not need to live beside your application data.
2. Directory model
One executable can run many independent Convex instances.
Example:
~/.local/bin/
└── convex-local-backend
~/Documents/Convex/
├── app1/
├── app2/
└── app3/
Each instance gets:
- its own process
- its own instance name
- its own instance secret
- its own admin key
- its own TCP ports
- its own SQLite database
- its own local storage directory
The same executable can be used by all of them.
This is the important model:
same convex-local-backend executable
|
├── process app1
│ └── database/storage app1
|
├── process app2
│ └── database/storage app2
|
└── process app3
└── database/storage app3
It is not one Convex process hosting multiple databases. Each deployment is a separate process.
Development setup
3. Create the first instance
Create a directory:
mkdir -p ~/Documents/Convex/app1
cd ~/Documents/Convex/app1
Generate a random instance secret:
openssl rand -hex 32
This returns a 64-character hexadecimal value.
Do not derive production instance secrets from short human passwords. A generated random 32-byte secret is the safer default.
The instance secret is extremely sensitive. Convex describes it as the root secret for the backend. Rotating it invalidates keys and sessions derived from it.
4. Create start.sh
For a local development instance:
nano start.sh
Example:
#!/bin/bash
cd "$(dirname "$0")" || exit 1
INSTANCE_NAME="app1"
INSTANCE_SECRET="PASTE_A_RANDOM_64_CHARACTER_SECRET_HERE"
exec convex-local-backend \
--instance-name "$INSTANCE_NAME" \
--instance-secret "$INSTANCE_SECRET" \
--disable-beacon
Protect it:
chmod 700 start.sh
Then start Convex:
./start.sh
A successful first startup contains messages similar to:
Connected to SQLite at convex_local_backend.sqlite3
...
backend listening on 0.0.0.0:3210
...
backend_http_proxy listening on 0.0.0.0:3211
After startup, the directory will contain data similar to:
app1/
├── start.sh
├── convex_local_backend.sqlite3
└── convex_local_storage/
├── files/
├── modules/
├── search/
├── exports/
└── snapshot_imports/
This proves that the database/storage follows the working directory.
About --disable-beacon
Self-hosted Convex includes a telemetry beacon that periodically contacts Convex.
We observed it send a request to:
https://api.convex.dev/api/self_host_beacon
Convex documents the beacon as containing minimal deployment information such as a random deployment identifier, migration version, backend Git revision, and uptime.
It is optional.
To disable it:
--disable-beacon
With the flag enabled, the local_backend::beacon startup/request lines disappear from the logs.
5. The UDF fetch requests are unrestricted warning
You may see:
Running without a proxy in release mode -- UDF `fetch` requests are unrestricted!
This is not a telemetry warning.
It means Convex functions that execute fetch() can make outbound network requests without a Convex-controlled filtering proxy.
For example:
await fetch("https://api.example.com/data");
can make an outbound request from the Convex host.
For local development this is usually acceptable.
For production, remember that a compromised or badly written function may be able to reach:
- public Internet services
- localhost services
- private network services reachable from the VM
Cloudflare Tunnel protects inbound access. It does not restrict outbound fetch() calls from Convex functions.
Admin keys
6. Generate the Convex admin key
The downloaded backend release does not currently include a precompiled generate_key helper.
Convex's official direct-binary instructions use the Rust keybroker utility from the source repository.
Clone the backend source once:
git clone https://github.com/get-convex/convex-backend.git
cd convex-backend
Use the same instance secret used by the backend:
export INSTANCE_SECRET="YOUR_APP1_INSTANCE_SECRET"
Generate the key:
cargo run -p keybroker --bin generate_key -- app1 "$INSTANCE_SECRET"
The resulting admin key looks conceptually like:
app1|...
Save the complete value in a password manager or another secure secret store.
The admin key is what the Convex CLI and dashboard use to administer that backend.
Build the helper once
Cargo can produce a reusable binary:
cargo build --release -p keybroker --bin generate_key
The result should be under:
target/release/generate_key
Install it:
cp target/release/generate_key ~/.local/bin/convex-generate-key
chmod +x ~/.local/bin/convex-generate-key
Then:
convex-generate-key app1 "$INSTANCE_SECRET"
The helper binary is CPU/OS specific.
An x86-64 Linux build should not be expected to run on ARM64 Linux. The generated admin key itself, however, is just credential data and is not architecture-specific.
You usually do not need the key generator on production servers.
Connect application code
7. Point a Convex project at the local backend
Inside the actual frontend/application project:
npm install convex@latest
Create:
.env.local
For app1:
CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=app1|PASTE_THE_COMPLETE_ADMIN_KEY
Do not commit .env.local.
Then:
npx convex dev
Successful output includes:
Developing against deployment:
└─ http://127.0.0.1:3210
Convex functions ready!
The CLI may also write frontend variables such as:
VITE_CONVEX_URL=http://127.0.0.1:3210
VITE_CONVEX_SITE_URL=http://127.0.0.1:3211
That is normal.
Warnings we encountered
Node localStorage experimental warning
We saw:
ExperimentalWarning: localStorage is not available because --localstorage-file was not provided.
The deployment still completed successfully. In our test this warning was not a Convex backend failure.
/tmp and project are on different filesystems
We saw:
Temporary directory '/tmp' and project directory ... are on different filesystems.
Convex says to use CONVEX_TMPDIR if this causes problems with filesystem watchers.
If everything works, it can be left alone.
Filesystem changed during push, retrying...
We saw this during npx convex dev.
Convex automatically retried and then reported:
Convex functions ready!
That was successful behavior.
8. Development process model
During active development, keep these running:
Terminal 1
./start.sh
Terminal 2
npx convex dev
And, for a Vite app:
Terminal 3
npm run dev
npx convex dev is a watcher. It continuously watches the convex/ code and pushes changes into the deployment.
It is development tooling, not the permanent production runtime.
Multiple independent apps on one machine
9. Create app2
Use a completely different secret and different ports.
Example allocation:
app1
backend: 3210
site: 3211
app2
backend: 3220
site: 3221
app3
backend: 3230
site: 3231
Create:
mkdir -p ~/Documents/Convex/app2
cd ~/Documents/Convex/app2
Generate a new random secret:
openssl rand -hex 32
Create start.sh:
#!/bin/bash
cd "$(dirname "$0")" || exit 1
INSTANCE_NAME="app2"
INSTANCE_SECRET="PASTE_APP2_SECRET_HERE"
exec convex-local-backend \
--instance-name "$INSTANCE_NAME" \
--instance-secret "$INSTANCE_SECRET" \
--port 3220 \
--site-proxy-port 3221 \
--convex-origin "http://127.0.0.1:3220" \
--convex-site "http://127.0.0.1:3221" \
--disable-beacon
Then:
chmod 700 start.sh
./start.sh
For local development, localhost origins are appropriate.
Generate a separate admin key:
convex-generate-key app2 "THE_APP2_INSTANCE_SECRET"
Point app2's project at:
CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3220
CONVEX_SELF_HOSTED_ADMIN_KEY=app2|THE_APP2_ADMIN_KEY
Then:
npx convex dev
You should see:
Developing against deployment:
└─ http://127.0.0.1:3220
Now app1 and app2 are isolated:
app1 project
↓
127.0.0.1:3210
↓
app1 Convex process
↓
app1 SQLite/storage
app2 project
↓
127.0.0.1:3220
↓
app2 Convex process
↓
app2 SQLite/storage
Stopping one process does not stop the other.
Deploying code to one does not deploy it to the other.
10. Verify processes
The simplest checks that worked in our test were:
curl -s http://127.0.0.1:3210/version; echo
curl -s http://127.0.0.1:3220/version; echo
Our precompiled build returned:
unknown
unknown
The important result was that both ports responded.
We also verified separate processes with:
ps aux | grep convex-local-backend
That showed two different PIDs and different instance/port arguments.
Security lesson: ps can expose the instance secret
Because the official direct-binary invocation passes:
--instance-secret <secret>
as a command-line argument, a process listing can expose that secret.
We observed exactly this with ps aux.
Therefore:
- never paste production process listings without redacting secrets
- use a dedicated Unix user for the Convex service in production
- restrict server access
- treat a secret shown in logs/chat/process listings as compromised
- regenerate exposed secrets before real production use
For a production host with multiple local users, consider additional OS process-visibility hardening.
Dashboard
11. The dashboard is optional
The dashboard is not the Convex server.
The application works with only:
frontend
↓
convex-local-backend
↓
SQLite/storage
The dashboard is an administrative client:
dashboard
↓
convex-local-backend
It can be stopped without stopping your application.
The same dashboard build can be used to administer different deployments by using the appropriate backend URL and matching admin key.
For a production environment, keep the dashboard private where possible, for example reachable only through Tailscale.
Production deployment
12. Replace convex dev with a one-time deploy
Development:
npx convex dev
Production release:
npx convex deploy
We tested a successful self-hosted deployment and saw:
Deploying code to deployment:
└─ http://127.0.0.1:3210
...
Schema validation complete.
Finalizing push...
Deployed Convex functions to http://127.0.0.1:3210
After npx convex deploy finishes, there is no permanent CLI process.
The production runtime is:
convex-local-backend
The CLI is only used again when deploying changed Convex functions.
Convex's documentation explicitly notes that the backend itself does not distinguish "development" from "production"; convex dev continuously deploys while convex deploy deploys once.
13. Build the frontend separately
For Vite:
npm run build
This normally produces:
dist/
For a local production-like test you can use:
npx vite preview
But vite preview is a preview server, not the preferred permanent production web server.
In this architecture, the cleanest production option is to host the built frontend separately, for example on a static hosting platform, and keep the VM focused on Convex.
That eliminates the frontend Node process from the VM.
Production networking: Tailscale + Cloudflare Tunnel
14. Why no Nginx or Caddy is required
Cloudflare Tunnel can map public hostnames directly to local HTTP services.
For Convex, that means cloudflared can talk directly to the backend ports.
The public path becomes:
user
↓ HTTPS
Cloudflare
↓ encrypted Cloudflare Tunnel
cloudflared
↓ localhost HTTP
Convex
Cloudflare handles public HTTPS.
The VM does not need a public port 80 or 443.
Cloudflare Tunnel uses outbound connections initiated by cloudflared, so the origin can have public inbound traffic blocked.
Cloudflare Tunnel also supports WebSockets, which is important for Convex realtime clients.
15. Use two public Convex hostnames
A direct Convex backend normally exposes:
backend/client API: 3210
HTTP actions/site: 3211
For production, use public HTTPS origins such as:
https://convex-api.example.com
https://convex-site.example.com
Cloudflare Tunnel routes them to:
convex-api.example.com
→ http://127.0.0.1:3210
convex-site.example.com
→ http://127.0.0.1:3211
Then the production Convex process should know its public origins.
Example production start.sh:
#!/bin/bash
cd "$(dirname "$0")" || exit 1
INSTANCE_NAME="app1"
INSTANCE_SECRET="$(cat ./instance-secret)"
exec convex-local-backend \
--instance-name "$INSTANCE_NAME" \
--instance-secret "$INSTANCE_SECRET" \
--port 3210 \
--site-proxy-port 3211 \
--convex-origin "https://convex-api.example.com" \
--convex-site "https://convex-site.example.com" \
--disable-beacon
Store the secret separately:
openssl rand -hex 32 > instance-secret
chmod 600 instance-secret
chmod 700 start.sh
Do not regenerate instance-secret on each boot.
The public origins matter because Convex may generate URLs that refer back to itself.
16. Cloudflare Tunnel routing
With a locally-managed tunnel, the conceptual ingress configuration is:
ingress:
- hostname: convex-api.example.com
service: http://127.0.0.1:3210
- hostname: convex-site.example.com
service: http://127.0.0.1:3211
- service: http_status:404
Cloudflare provides the public HTTPS certificates.
The local hop can remain plain HTTP because:
cloudflared → 127.0.0.1
stays on the VM.
If your Cloudflare account has a WebSockets setting, make sure WebSockets are enabled. Cloudflare documents full WebSocket support for Tunnel.
For a second Convex app:
ingress:
- hostname: app1-api.example.com
service: http://127.0.0.1:3210
- hostname: app1-site.example.com
service: http://127.0.0.1:3211
- hostname: app2-api.example.com
service: http://127.0.0.1:3220
- hostname: app2-site.example.com
service: http://127.0.0.1:3221
- service: http_status:404
No local reverse proxy is required just to perform hostname routing.
Add Caddy/Nginx only if you later need a feature Cloudflare Tunnel is not providing, such as complicated local rewrite logic or local load balancing.
17. Production application environment
When deploying from your development/CI machine to the production backend:
CONVEX_SELF_HOSTED_URL=https://convex-api.example.com
CONVEX_SELF_HOSTED_ADMIN_KEY=app1|PRODUCTION_ADMIN_KEY
Then:
npx convex deploy
Do not use:
http://127.0.0.1:3210
from a remote development machine; that would refer to the development machine itself.
The frontend production build should also receive the public Convex URLs appropriate to its framework, for example:
VITE_CONVEX_URL=https://convex-api.example.com
VITE_CONVEX_SITE_URL=https://convex-site.example.com
The exact frontend variable names depend on the framework and what the Convex CLI/project generated.
Tailscale and firewall
18. Use Tailscale for administration
Tailscale lets you keep SSH and administrative services off the public Internet.
Typical administration path:
your laptop
↓ Tailscale
production VM
├── SSH
├── logs
└── private dashboard
Tailscale's current documentation says most installations do not require manually opening an inbound firewall port. When direct peer-to-peer connectivity is not possible it can fall back to relays.
Before blocking public SSH, verify that you can log in using the VM's Tailscale IP.
Get it with:
tailscale ip -4
Test:
ssh user@100.x.y.z
Only after that should you remove public SSH access.
19. UFW model for the VM
A minimal model:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw enable
Review:
sudo ufw status verbose
With Cloudflare Tunnel, you do not need public UFW rules for:
22
80
443
3210
3211
3220
3221
assuming administration is through Tailscale and all public app traffic comes through cloudflared.
Cloudflare documents that Tunnel can work with inbound traffic blocked because cloudflared creates outbound-only connections.
Do not lock yourself out: verify Tailscale SSH/network access before deleting an existing public SSH rule.
systemd production service
20. Keep Convex running without a terminal
Create a service unit such as:
/etc/systemd/system/convex-app1.service
Example:
[Unit]
Description=Convex app1 backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=convex
WorkingDirectory=/srv/convex/app1
ExecStart=/srv/convex/app1/start.sh
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
The application directory could be:
/srv/convex/app1/
├── start.sh
├── instance-secret
├── convex_local_backend.sqlite3
└── convex_local_storage/
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now convex-app1
Check:
systemctl status convex-app1
Logs:
journalctl -u convex-app1 -f
For app2, create another unit using its own directory and ports.
Resource usage findings
21. What we observed
In our small idle test:
| App | Approx. RAM |
|---|---|
convex-tutorial production-like process set |
~276 MB |
presence-typing-indicator production-like process set |
~348 MB |
| Combined | ~624 MB |
Idle CPU was negligible.
With duplicate development stacks using convex dev, measured combined usage had previously been roughly 1.5 GB.
Moving to:
convex-local-backend
+
one-time convex deploy
+
built frontend
reduced the observed idle footprint substantially.
These are environment-specific measurements, not guaranteed Convex requirements.
For real capacity planning, measure:
- VM baseline before apps
- incremental memory after each Convex instance
- real traffic
- search/vector workloads
- file storage
- scheduled jobs
- OS/cache usage
PSS is preferable to blindly summing RSS when possible.
For a 2 GB VM, a conservative starting target is usually fewer applications than the mathematical maximum. Leave headroom for Linux, Tailscale, cloudflared, filesystem cache, traffic bursts, and maintenance operations.
Important production notes
22. SQLite is the minimum, not necessarily the final database architecture
Convex defaults to SQLite for self-hosting and recommends starting with the basic configuration.
For a mini production environment this can be useful because it eliminates a separate database server.
However, production requirements eventually determine whether you should move to Postgres/MySQL and/or external object storage.
At minimum, back up:
convex_local_backend.sqlite3
convex_local_storage/
and test restoration.
Do not assume a copied live SQLite file is a valid backup without using an appropriate consistent-backup procedure.
23. Do not confuse three different kinds of environment variables
Backend process configuration
Used to configure the convex-local-backend process itself.
In this runbook we mostly use explicit flags in start.sh.
Convex CLI self-hosted connection
Used by your source project:
CONVEX_SELF_HOSTED_URL=...
CONVEX_SELF_HOSTED_ADMIN_KEY=...
These tell npx convex dev and npx convex deploy which self-hosted backend to administer.
Convex function environment variables
Application secrets such as third-party API keys belong inside the Convex deployment environment and are accessed by Convex functions.
These are conceptually separate from the instance secret and admin key.
Common mistakes from this test
24. Mistakes to avoid
Mistake: thinking the dashboard is required
It is not. It is an admin UI.
Mistake: leaving npx convex dev running in production
Use:
npx convex deploy
for a one-time deployment.
Keep only convex-local-backend running.
Mistake: using one secret for multiple apps
Each independent backend should have its own:
- instance secret
- admin key
- ports
- database/storage directory
Mistake: running two instances on the same ports
Allocate separate pairs:
3210/3211
3220/3221
3230/3231
Mistake: regenerating INSTANCE_SECRET every boot
Do not do this.
The same deployment must keep the same instance secret unless you intentionally rotate it.
Mistake: exposing secrets with ps
Direct CLI arguments can be visible in process listings.
Do not publish unredacted ps output.
Mistake: assuming /version must display a release number
Our precompiled backend returned:
unknown
while functioning correctly.
Use it mainly as a reachability/health signal unless your build provides a meaningful version string.
Mistake: thinking Cloudflare Tunnel restricts Convex fetch()
It does not.
Tunnel handles inbound/public access. Convex UDF fetch() is outbound traffic.
Mistake: keeping localhost origins in production
Local development:
http://127.0.0.1:3210
http://127.0.0.1:3211
Production behind Cloudflare:
https://convex-api.example.com
https://convex-site.example.com
The production backend should know its public origins.
Mistake: assuming Vite preview is the final production host
vite preview is useful to test a production build.
For a real deployment, host the static build on a proper static host. If the frontend is hosted away from the Convex VM, the VM uses less RAM.
Final architecture
Development
Developer PC
app source
|
├── npm run dev
│ ↓
│ Vite frontend
│
└── npx convex dev
↓
localhost:3210
↓
convex-local-backend
↓
SQLite + local storage
Production
┌─────────────────────┐
│ Static frontend │
│ (separate hosting) │
└──────────┬──────────┘
|
| HTTPS
v
Browser
|
| HTTPS / WebSocket
v
Cloudflare edge
|
Cloudflare Tunnel
|
v
production VM
┌──────────────┼──────────────┐
│ │ │
tailscaled cloudflared systemd
│ │ │
private admin │ convex-local-backend
│ │
├── :3210 API │
└── :3211 site │
│
SQLite/storage
Public inbound firewall:
DENY
Administrative access:
Tailscale
Public application access:
Cloudflare Tunnel
This is the minimum production stack we were aiming for:
Convex binary
+ SQLite/local storage
+ systemd
+ Tailscale
+ Cloudflare Tunnel
No container runtime and no local reverse proxy are inherently required.
Quick rebuild checklist
If rebuilding from zero:
- Download the correct
convex-local-backendarchitecture. - Put it in
~/.local/binor a system binary directory. - Create a dedicated directory for each instance.
- Generate a random 32-byte instance secret.
- Create
start.sh. - Assign unique ports for each instance.
- Start the backend and confirm SQLite/storage paths.
- Build
convex-generate-keyonce from the Convex source repository. - Generate a unique admin key for each backend.
- Put
CONVEX_SELF_HOSTED_URLandCONVEX_SELF_HOSTED_ADMIN_KEYin the application project's uncommitted environment file. - Develop with
npx convex dev. - Release backend functions with
npx convex deploy. - Build the frontend separately.
- On production, set Convex's public origins to the Cloudflare HTTPS hostnames.
- Route the API/site hostnames through Cloudflare Tunnel to the local Convex ports.
- Verify Cloudflare WebSockets are enabled.
- Install Tailscale and verify remote access through the tailnet.
- Deny public inbound traffic and allow the
tailscale0interface. - Run Convex under systemd.
- Back up the SQLite database and local storage, and test restores.
Sources
This runbook combines direct testing with current official documentation. Re-check these sources before a future major upgrade because self-hosted Convex is actively developed.
- Convex self-hosting README: https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md
- Convex: running the precompiled binary directly: https://github.com/get-convex/convex-backend/blob/main/self-hosted/advanced/running_binary_directly.md
- Convex self-hosted Fly guide, including
convex devvsconvex deployand public origins: https://github.com/get-convex/convex-backend/blob/main/self-hosted/advanced/fly/README.md - Convex backend repository/self-hosting notes and beacon description: https://github.com/get-convex/convex-backend
- Cloudflare Tunnel overview: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
- Cloudflare published applications: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/
- Cloudflare Tunnel WebSocket support: https://developers.cloudflare.com/cloudflare-one/faq/cloudflare-tunnels-faq/
- Tailscale firewall-port guidance: https://tailscale.com/docs/reference/faq/firewall-ports
- Tailscale UFW server-lockdown guide: https://tailscale.com/docs/how-to/secure-ubuntu-server-with-ufw
Top comments (0)