DEV Community

Cover image for Bare-Metal Convex: Develop and Deploy with the Minimum Stack Possible
Fredy Sandoval
Fredy Sandoval

Posted on

Bare-Metal Convex: Develop and Deploy with the Minimum Stack Possible

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For ARM64 Linux, use:

convex-local-backend-aarch64-unknown-linux-gnu.zip
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The ZIP contains one large executable:

convex-local-backend
Enter fullscreen mode Exit fullscreen mode

A convenient per-user installation location is:

mkdir -p ~/.local/bin
mv convex-local-backend ~/.local/bin/
chmod +x ~/.local/bin/convex-local-backend
Enter fullscreen mode Exit fullscreen mode

Verify:

convex-local-backend --help
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Generate a random instance secret:

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Protect it:

chmod 700 start.sh
Enter fullscreen mode Exit fullscreen mode

Then start Convex:

./start.sh
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use the same instance secret used by the backend:

export INSTANCE_SECRET="YOUR_APP1_INSTANCE_SECRET"
Enter fullscreen mode Exit fullscreen mode

Generate the key:

cargo run -p keybroker --bin generate_key -- app1 "$INSTANCE_SECRET"
Enter fullscreen mode Exit fullscreen mode

The resulting admin key looks conceptually like:

app1|...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The result should be under:

target/release/generate_key
Enter fullscreen mode Exit fullscreen mode

Install it:

cp target/release/generate_key ~/.local/bin/convex-generate-key
chmod +x ~/.local/bin/convex-generate-key
Enter fullscreen mode Exit fullscreen mode

Then:

convex-generate-key app1 "$INSTANCE_SECRET"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create:

.env.local
Enter fullscreen mode Exit fullscreen mode

For app1:

CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=app1|PASTE_THE_COMPLETE_ADMIN_KEY
Enter fullscreen mode Exit fullscreen mode

Do not commit .env.local.

Then:

npx convex dev
Enter fullscreen mode Exit fullscreen mode

Successful output includes:

Developing against deployment:
└─ http://127.0.0.1:3210

Convex functions ready!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That is normal.

Warnings we encountered

Node localStorage experimental warning

We saw:

ExperimentalWarning: localStorage is not available because --localstorage-file was not provided.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

That was successful behavior.


8. Development process model

During active development, keep these running:

Terminal 1
./start.sh
Enter fullscreen mode Exit fullscreen mode
Terminal 2
npx convex dev
Enter fullscreen mode Exit fullscreen mode

And, for a Vite app:

Terminal 3
npm run dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create:

mkdir -p ~/Documents/Convex/app2
cd ~/Documents/Convex/app2
Enter fullscreen mode Exit fullscreen mode

Generate a new random secret:

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

chmod 700 start.sh
./start.sh
Enter fullscreen mode Exit fullscreen mode

For local development, localhost origins are appropriate.

Generate a separate admin key:

convex-generate-key app2 "THE_APP2_INSTANCE_SECRET"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

npx convex dev
Enter fullscreen mode Exit fullscreen mode

You should see:

Developing against deployment:
└─ http://127.0.0.1:3220
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Our precompiled build returned:

unknown
unknown
Enter fullscreen mode Exit fullscreen mode

The important result was that both ports responded.

We also verified separate processes with:

ps aux | grep convex-local-backend
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The dashboard is an administrative client:

dashboard
   ↓
convex-local-backend
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Production release:

npx convex deploy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After npx convex deploy finishes, there is no permanent CLI process.

The production runtime is:

convex-local-backend
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This normally produces:

dist/
Enter fullscreen mode Exit fullscreen mode

For a local production-like test you can use:

npx vite preview
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For production, use public HTTPS origins such as:

https://convex-api.example.com
https://convex-site.example.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Store the secret separately:

openssl rand -hex 32 > instance-secret
chmod 600 instance-secret
chmod 700 start.sh
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Cloudflare provides the public HTTPS certificates.

The local hop can remain plain HTTP because:

cloudflared → 127.0.0.1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

npx convex deploy
Enter fullscreen mode Exit fullscreen mode

Do not use:

http://127.0.0.1:3210
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test:

ssh user@100.x.y.z
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Review:

sudo ufw status verbose
Enter fullscreen mode Exit fullscreen mode

With Cloudflare Tunnel, you do not need public UFW rules for:

22
80
443
3210
3211
3220
3221
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The application directory could be:

/srv/convex/app1/
├── start.sh
├── instance-secret
├── convex_local_backend.sqlite3
└── convex_local_storage/
Enter fullscreen mode Exit fullscreen mode

Then:

sudo systemctl daemon-reload
sudo systemctl enable --now convex-app1
Enter fullscreen mode Exit fullscreen mode

Check:

systemctl status convex-app1
Enter fullscreen mode Exit fullscreen mode

Logs:

journalctl -u convex-app1 -f
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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=...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Production behind Cloudflare:

https://convex-api.example.com
https://convex-site.example.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is the minimum production stack we were aiming for:

Convex binary
+ SQLite/local storage
+ systemd
+ Tailscale
+ Cloudflare Tunnel
Enter fullscreen mode Exit fullscreen mode

No container runtime and no local reverse proxy are inherently required.


Quick rebuild checklist

If rebuilding from zero:

  1. Download the correct convex-local-backend architecture.
  2. Put it in ~/.local/bin or a system binary directory.
  3. Create a dedicated directory for each instance.
  4. Generate a random 32-byte instance secret.
  5. Create start.sh.
  6. Assign unique ports for each instance.
  7. Start the backend and confirm SQLite/storage paths.
  8. Build convex-generate-key once from the Convex source repository.
  9. Generate a unique admin key for each backend.
  10. Put CONVEX_SELF_HOSTED_URL and CONVEX_SELF_HOSTED_ADMIN_KEY in the application project's uncommitted environment file.
  11. Develop with npx convex dev.
  12. Release backend functions with npx convex deploy.
  13. Build the frontend separately.
  14. On production, set Convex's public origins to the Cloudflare HTTPS hostnames.
  15. Route the API/site hostnames through Cloudflare Tunnel to the local Convex ports.
  16. Verify Cloudflare WebSockets are enabled.
  17. Install Tailscale and verify remote access through the tailnet.
  18. Deny public inbound traffic and allow the tailscale0 interface.
  19. Run Convex under systemd.
  20. 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.

Top comments (0)