Deploy a Self-Hosted zrok Tunnel Server
Expose local services, share files, and build zero-trust access workflows — without relying on a third-party cloud. ngrok-style sharing with full infrastructure control.
At a Glance
| Project | zrok by NetFoundry (built on OpenZiti) |
| License | Apache 2.0 |
| Recommended Plan | RamNode KVM2 (2 GB RAM) or higher |
| OS | Ubuntu 22.04 LTS |
| Controller Port | 18080 (proxied to 443 via Nginx) |
| Database | PostgreSQL |
| DNS Requirement | Wildcard A record (*.zrok.example.com) |
| Estimated Setup Time | 25–35 minutes |
What Is zrok?
zrok (pronounced "zee-rock") is an open-source tunneling and sharing platform built on OpenZiti. It supports multiple share types:
- Public shares — expose a local HTTP service at a publicly accessible URL
- Private shares — share between two authenticated clients without public exposure
- Reserved shares — persistent shares with a stable subdomain that survive restarts
Architecture Overview
[zrok client] ----> [zrok controller (your VPS)] ----> [OpenZiti network]
|
[PostgreSQL DB]
[zrok web UI]The controller handles account registration, token issuance, share management, and the web console. OpenZiti runs as an embedded component — no separate installation needed.
Prerequisites
- A RamNode VPS running Ubuntu 22.04 LTS with at least 1 GB RAM and 20 GB disk (2 GB KVM plan works well)
- A domain pointed at your VPS IP (e.g.,
zrok.example.com) - Wildcard DNS configured:
*.zrok.example.com→ your VPS IP - A non-root sudo user on the server
- Ports 80, 443, and 18080 open in your firewall
Point DNS
Configure two DNS records at your registrar:
| Type | Name | Value |
|---|---|---|
| A | zrok.example.com | Your VPS public IP |
| A | *.zrok.example.com | Your VPS public IP |
The wildcard record is required — zrok assigns a unique subdomain to every public share. Allow a few minutes for DNS propagation.
Install Dependencies
sudo apt update && sudo apt upgrade -ysudo apt install -y postgresql postgresql-contrib certbot python3-certbot-nginx nginxsudo systemctl enable --now postgresqlCreate the PostgreSQL Database
sudo -u postgres psqlCREATE USER zrok WITH PASSWORD 'strongpassword';
CREATE DATABASE zrok OWNER zrok;
GRANT ALL PRIVILEGES ON DATABASE zrok TO zrok;
\qDownload and Install zrok
cd /tmp
curl -LO https://github.com/openziti/zrok/releases/latest/download/zrok_linux_amd64.tar.gz
tar -xzf zrok_linux_amd64.tar.gz
sudo mv zrok /usr/local/bin/
sudo chmod +x /usr/local/bin/zrokzrok versionConfigure the zrok Controller
sudo useradd --system --no-create-home --shell /bin/false zrok
sudo mkdir -p /etc/zrok-controller
sudo chown zrok:zrok /etc/zrok-controllersudo nano /etc/zrok-controller/config.yamlv: 4
admin:
secrets:
- "PASTE_RANDOM_HEX_HERE"
endpoint:
host: 0.0.0.0
port: 18080
store:
path: "postgres://zrok:strongpassword@localhost/zrok?sslmode=disable"
ziti:
api_endpoint: "https://zrok.example.com:1280"
domain:
base_url: "https://zrok.example.com"
email:
host: "localhost"
port: 25
from: "zrok@example.com"
tls: falseGenerate the admin secret with: openssl rand -hex 32
sudo chown zrok:zrok /etc/zrok-controller/config.yaml
sudo chmod 600 /etc/zrok-controller/config.yamlInitialize the Controller
sudo -u zrok zrok admin bootstrap /etc/zrok-controller/config.yamlThis creates the database tables and generates the embedded OpenZiti configuration. Safe to re-run if needed.
Create the systemd Service
sudo nano /etc/systemd/system/zrok-controller.service[Unit]
Description=zrok Controller
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=zrok
Group=zrok
ExecStart=/usr/local/bin/zrok controller /etc/zrok-controller/config.yaml
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now zrok-controllersudo systemctl status zrok-controllerIf it fails, check the journal:
sudo journalctl -u zrok-controller -fConfigure Nginx and TLS
Obtain a wildcard certificate. Use a DNS challenge — the certbot manual method or a DNS plugin for your provider:
sudo certbot certonly --manual --preferred-challenges dns \
-d zrok.example.com \
-d "*.zrok.example.com"Create the Nginx site config:
# Redirect HTTP to HTTPS
server {
listen 80;
server_name zrok.example.com *.zrok.example.com;
return 301 https://$host$request_uri;
}
# Main zrok controller proxy
server {
listen 443 ssl;
server_name zrok.example.com;
ssl_certificate /etc/letsencrypt/live/zrok.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zrok.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Wildcard proxy for public shares
server {
listen 443 ssl;
server_name *.zrok.example.com;
ssl_certificate /etc/letsencrypt/live/zrok.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zrok.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Important: The wildcard server block routes every *.zrok.example.com request to the controller, which identifies the correct share via the hostname.
sudo ln -s /etc/nginx/sites-available/zrok /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginxCreate an Admin Account
zrok admin create account /etc/zrok-controller/config.yaml \
admin@example.com \
securepasswordReplace the email and password with real values. Save the account token from the output. Log in at https://zrok.example.com to confirm.
Open Firewall Ports
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 18080/tcp
sudo ufw reloadPort 18080 is used directly by the zrok CLI for WebSocket connections during active shares. Nginx handles browser traffic on 443.
Install the zrok Client Locally
On the machine where you want to run shares, download the zrok binary:
curl -LO https://github.com/openziti/zrok/releases/latest/download/zrok_linux_amd64.tar.gz
tar -xzf zrok_linux_amd64.tar.gz
sudo mv zrok /usr/local/bin/Point the client at your self-hosted controller:
zrok config set apiEndpoint https://zrok.example.comzrok enable YOUR_ACCOUNT_TOKENCreate Your First Share
Start a test HTTP server and create a public share:
python3 -m http.server 8080zrok share public localhost:8080zrok will output a URL like https://abc123def.zrok.example.com. Open it in a browser to verify. Press Ctrl+C to stop the share.
Additional Configuration
User Registration
By default, new accounts require an invite token. For open self-registration, add to the controller config:
registration:
registration_token_strategy: opensudo systemctl restart zrok-controllerFor closed environments, generate invite tokens:
zrok admin generate account /etc/zrok-controller/config.yaml user@example.comCertificate Renewal
Wildcard certificates must be renewed every 90 days. Add a post-hook to auto-reload Nginx:
sudo nano /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.sh#!/bin/bash
systemctl reload nginxsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.shsudo systemctl status certbot.timerTroubleshooting
Controller service fails to start
Check the journal with sudo journalctl -u zrok-controller -n 50. Common causes: wrong database connection string, missing admin secret, or port conflict on 18080.
sudo ss -tlnp | grep 18080Public share URLs return 502
Confirm wildcard DNS is resolving with dig abc123.zrok.example.com. Verify Nginx is proxying the Host header unchanged.
zrok enable fails on the client
Run zrok config show and confirm apiEndpoint is set correctly with HTTPS. Self-signed certificates will cause client failures.
Shares disconnect after a few minutes
Add proxy timeouts to the Nginx location blocks:
proxy_read_timeout 3600;
proxy_send_timeout 3600;