Your own private NVDA Remote relay server. One file. Zero dependencies. Deploy anywhere in seconds.
A lightweight, single-file relay server for NVDA Remote Access -- the built-in remote control feature in NVDA 2025.1 and later. Run your own private server and keep your remote sessions under your control.
Remote Access was originally the NVDA Remote add-on, developed by Tyler Spivey and Christopher Toth. Starting with NVDA 2025.1, it ships as a built-in feature -- no add-on required. This server is compatible with both the built-in feature and the legacy add-on.
The default public NVDA Remote servers work, but they're shared infrastructure. Running your own server means:
- Privacy -- your keystrokes and speech never touch a shared relay
- Reliability -- no dependency on community-maintained servers
- Control -- configure TLS, message limits, and MOTD on your terms
- Low latency -- deploy in the region closest to you and your peers
This is especially valuable for blind system administrators who need reliable, private remote access to machines on their network.
Pick your path. All roads lead to a running server.
The fastest way to get a server running in the cloud. The full process takes about 5 minutes.
Step 1: Click the Deploy button
Click the "Deploy on Railway" button above. This opens Railway's template creation page in a new tab.
Step 2: Authenticate and clone
Railway will ask you to connect your GitHub account if you aren't logged in already. It will prompt you to name the new repository it creates for you.
- You can leave the default name or change it to something like
nvda-rockstorm-server. - Check the box to make the repository Private. Even though we use environment variables for the certificate, privacy is a good default.
Step 3: Configure environment variables
Because of the railway.json file, Railway will present you with a form asking for your environment variables before it attempts to deploy.
| Variable | What to enter |
|---|---|
NVDA_REMOTE_MOTD |
A message shown to users on connect (optional). |
NVDA_REMOTE_CERT_CONTENT |
Leave blank to auto-generate a self-signed certificate on startup, or paste a base64-encoded PEM string (see Certificate via environment variable below). |
NVDA_REMOTE_DEBUG |
false (unless you want raw JSON in the logs). |
NVDA_REMOTE_PORT |
6837 |
Step 4: Hit Deploy
Click the Deploy button at the bottom of the form. Railway will create the project, pull the Docker image, and start building.
This is the one step the template cannot do for you. NVDA Remote uses raw TCP over TLS -- not HTTP -- so you need to enable Railway's TCP proxy. This can only be done through the Railway dashboard (there is no CLI command for it).
Step 1: Open the service settings
Once the project dashboard loads, you'll see a block representing your deployed service. Click on it.
Step 2: Navigate to Networking
In the panel that opens, select the Settings tab and find the Networking section.
Step 3: Enable TCP Proxy
Click on TCP Proxy and enter the internal port your service listens on. This is the port you set in NVDA_REMOTE_PORT (or 6837 if you left it at the default).
Step 4: Capture your server address
Railway will generate a proxy domain and port for you. It will look something like:
shuttle.proxy.rlwy.net:15140
Copy this address. The part before the colon is your host, and the number after the colon is your public port. This is what you'll enter in NVDA to connect.
You can point a custom domain at your Railway TCP proxy using a DNS CNAME record, but there's an important limitation to understand.
HTTP vs. TCP routing: When Railway hosts a regular website, it routes traffic by domain name, so custom domains work seamlessly on port 443. But NVDA Remote uses raw TCP. Railway handles this by putting you on a shared server with a random dedicated port (like 51106). DNS CNAME records only map names to names -- they can't remap ports.
Setting up the CNAME:
If Railway gave you shuttle.proxy.rlwy.net:15140, create a DNS record like this:
| Type | Name | Target |
|---|---|---|
| CNAME | remote |
shuttle.proxy.rlwy.net |
Note: drop the port from the target -- DNS breaks if you include it.
Your users would then connect with:
- Host:
remote.yourdomain.com - Port:
15140
This gives you a branded hostname, but users still need to type the port number.
Want a completely clean address? If your goal is remote.yourdomain.com with no port (just the default 6837), Railway's TCP proxy can't do that because it doesn't give you a dedicated IP. For a true port-free custom domain, run this on a VPS (DigitalOcean, Linode, Hetzner -- as cheap as $5/month) where you get your own IPv4 address and full control over which port listens.
Prebuilt multi-arch images (amd64 + arm64) are published to GitHub Container Registry on every push to main.
Pull the image:
docker pull ghcr.io/a2hsh/nvda-remote:latestUsing Docker Compose (recommended):
The easiest way to run the server. Grab the compose file and go:
curl -O https://raw.githubusercontent.com/a2hsh/nvda-remote/main/docker-compose.yml
# Edit docker-compose.yml to set your environment variables, then:
docker compose up -dUsing docker run:
Minimal -- auto-generates a self-signed certificate:
docker run -d \
-p 6837:6837 \
--name nvda-remote \
--restart unless-stopped \
ghcr.io/a2hsh/nvda-remote:latestWith all the bells and whistles:
docker run -d \
-p 6837:6837 \
--name nvda-remote \
--restart unless-stopped \
-e NVDA_REMOTE_PORT=6837 \
-e NVDA_REMOTE_MOTD="Welcome to my NVDA Remote server." \
-e NVDA_REMOTE_CERT_CONTENT="$(base64 -w0 server.pem)" \
ghcr.io/a2hsh/nvda-remote:latestPin to a specific version if you prefer stability over living on the edge:
docker run -d \
-p 6837:6837 \
--name nvda-remote \
--restart unless-stopped \
ghcr.io/a2hsh/nvda-remote:1.0.0Building locally:
docker build -t nvda-remote .
docker run -d -p 6837:6837 --name nvda-remote --restart unless-stopped nvda-remoteNo Docker? No problem. Requires Python 3.8+ and OpenSSL.
python server.pyThat's it. One file, zero pip install, pure Python standard library. The server will auto-generate a self-signed certificate on first run if none exists.
All options can be set via command-line arguments or environment variables. Environment variables make it easy to configure in Docker, Railway, or any platform that supports them.
| Argument | Environment Variable | Default | Description |
|---|---|---|---|
--port |
NVDA_REMOTE_PORT |
6837 |
Port to listen on |
--certfile |
NVDA_REMOTE_CERTFILE |
server.pem |
Path to TLS certificate file |
--keyfile |
NVDA_REMOTE_KEYFILE |
server.pem |
Path to TLS key file |
--motd |
NVDA_REMOTE_MOTD |
(none) | Message of the day shown on join |
--motd-force |
NVDA_REMOTE_MOTD_FORCE |
false |
Force-display the MOTD |
--max-msg-size |
NVDA_REMOTE_MAX_MSG_SIZE |
1048576 |
Max message size in bytes (0 = unlimited) |
--debug |
NVDA_REMOTE_DEBUG |
false |
Enable debug logging |
--tracebacks |
NVDA_REMOTE_TRACEBACKS |
false |
Log full tracebacks on errors |
--generate-cert |
-- | -- | Generate a self-signed certificate and exit |
The server requires TLS -- every connection is encrypted. On first run, it will auto-generate a self-signed certificate (server.pem) using OpenSSL. For production, you have several options:
Provide your own certificate file:
python server.py --certfile /path/to/cert.pem --keyfile /path/to/key.pemGenerate a fresh self-signed certificate:
python server.py --generate-certOn platforms like Railway where you can't upload files and the filesystem is ephemeral, you can inject a certificate entirely through an environment variable. The server will decode it and write the PEM file at startup -- no file uploads needed.
Step 1: Generate a certificate locally
python server.py --generate-certThis creates server.pem in the current directory (a combined certificate + private key file).
Step 2: Base64-encode the certificate
# Linux / macOS
base64 -w0 server.pem
# Windows (PowerShell)
[Convert]::ToBase64String([IO.File]::ReadAllBytes("server.pem"))Copy the output -- this is your certificate as a single string with no line breaks.
Step 3: Set the environment variable
On Railway, go to your project's Variables tab and add:
| Variable | Value |
|---|---|
NVDA_REMOTE_CERT_CONTENT |
(paste the base64 string from step 2) |
You can also set it with the Railway CLI:
railway variables set NVDA_REMOTE_CERT_CONTENT="$(base64 -w0 server.pem)"Or in Docker:
docker run -d -p 6837:6837 \
-e NVDA_REMOTE_CERT_CONTENT="$(base64 -w0 server.pem)" \
ghcr.io/a2hsh/nvda-remote:latestWhen the server starts, it decodes NVDA_REMOTE_CERT_CONTENT and writes it to the certificate path (default server.pem). This happens before the TLS listener starts, so the server is ready immediately.
As of NVDA 2025.1, Remote Access is built in. No add-on needed.
- In NVDA, go to Tools > Remote > Connect
- Select Control another machine or Allow this machine to be controlled
- Enter your server address -- for example:
proxy.rlwy.net:12345-- Railway TCP proxyremote.yourdomain.com:51106-- custom domain on Railwayyourserver.example.com:6837-- self-hosted (VPS, home server, etc.)
- Enter or generate a key
- Click Connect
Both users must connect to the same server with the same key.
Still on an older version of NVDA? The legacy NVDA Remote add-on works with this server too.
This is a single-file asyncio server (~300 lines of Python) that acts as a message relay:
- Clients connect over TLS and join a channel identified by a shared key
- Messages from one client are broadcast to all other clients in the same channel
- Supports both NVDA Remote protocol v1 and v2 clients simultaneously
- Uses TCP keepalive for dead connection detection (no application-level ping overhead)
- Per-client write queues with backpressure (queue full = connection closed)
- Channels are created on first join and destroyed when the last client leaves
No database. No framework. No dependencies. Just Python and a socket.
The project uses a VERSION file as the single source of truth. To release:
- Update the version in
VERSION - Commit the change
- Run
./release.sh
The script will tag, push, and trigger the CI to build Docker images for the new version. Images are tagged as latest, X.Y.Z, X.Y, and X.
MIT -- see LICENSE for details.