> ## Documentation Index
> Fetch the complete documentation index at: https://rustunnel.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy rustunnel-server with Docker — local dev or VPS production

> Deploy the rustunnel tunnel server with Docker. Covers a local-development setup with a self-signed certificate and a production VPS deployment with a real domain and Let's Encrypt TLS — pair with the systemd-based self-hosting guide.

This guide walks through how to **deploy rustunnel-server with Docker**, in two scenarios:

* **[Scenario A — Local development](#scenario-a-local-development)**: run the server on your laptop using a self-signed certificate.
* **[Scenario B — VPS production](#scenario-b-vps-production)**: deploy on a cloud server with a real domain and Let's Encrypt TLS.

For a non-Docker, systemd-based production deployment see the [self-hosting guide](/docs/guides/self-hosting). For the architecture behind the server see the [architecture reference](/docs/guides/architecture).

***

## Prerequisites

| Tool           | Version     | Notes                                                     |
| -------------- | ----------- | --------------------------------------------------------- |
| Docker Engine  | 24+         | [Install docs](https://docs.docker.com/engine/install/)   |
| Docker Compose | v2 (plugin) | Bundled with Docker Desktop; `docker compose` (no hyphen) |
| Git            | any         | To clone the repository                                   |

Clone the repository once:

```bash theme={null}
git clone https://github.com/joaoh82/rustunnel.git
cd rustunnel
```

***

## How the Docker image works

The `deploy/Dockerfile` is a three-stage build:

1. **`ui-builder`** — installs Node 20, runs `next build` on the dashboard UI, and produces the static export under `out/`.
2. **`builder`** — compiles the Rust server. The `out/` directory is copied into `crates/rustunnel-server/src/dashboard/assets/` so `rust-embed` can bake it into the binary at compile time.
3. **`runtime`** — minimal `debian:bookworm-slim` image containing only the server binary and `ca-certificates`.

<Note>Because both the UI and the Rust binary are built inside Docker you do **not** need Node.js or Rust installed on the host to build or run the image.</Note>

***

## Scenario A — Local development

Use this when you want to run a full server stack on your laptop for testing or development.

### 1 — Generate a self-signed certificate

```bash theme={null}
mkdir -p /tmp/rustunnel-certs

openssl req -x509 -newkey rsa:2048 \
  -keyout /tmp/rustunnel-certs/key.pem \
  -out    /tmp/rustunnel-certs/cert.pem \
  -days 365 -nodes \
  -subj "/CN=localhost"
```

The compose file mounts this directory into the container at `/certs`.
To use a different path, set the `CERT_DIR` environment variable before running compose.

### 2 — Build the image

```bash theme={null}
make docker-build
# equivalent: docker build -f deploy/Dockerfile -t rustunnel-server:latest .
```

The first build takes several minutes (Rust + Node.js compilation).
Subsequent builds use Docker layer caching and are much faster unless `Cargo.lock` or `package-lock.json` change.

### 3 — Start the server

```bash theme={null}
docker compose -f deploy/docker-compose.local.yml up
```

Add `-d` to detach:

```bash theme={null}
docker compose -f deploy/docker-compose.local.yml up -d
```

### 4 — Verify it is running

```bash theme={null}
# Health check (HTTP — no TLS required for dashboard in local mode)
curl http://localhost:4041/api/status

# Open the dashboard in a browser
open http://localhost:4041
```

### 5 — Connect a client

```bash theme={null}
# Expose a local service running on port 3000
rustunnel http 3000 \
  --server localhost:4040 \
  --token dev-secret-change-me \
  --insecure
```

<Warning>`--insecure` skips TLS verification. Required for self-signed certificates. **Never** use this flag against a production server.</Warning>

### 6 — Stop the server

```bash theme={null}
docker compose -f deploy/docker-compose.local.yml down
```

### Port reference (local)

| Port          | Purpose                                        |
| ------------- | ---------------------------------------------- |
| `4040`        | Control-plane WebSocket — clients connect here |
| `4041`        | Dashboard UI and REST API                      |
| `8080`        | HTTP edge (tunnel ingress, redirects to HTTPS) |
| `8443`        | HTTPS edge (TLS-terminated tunnel ingress)     |
| `20000–20099` | TCP tunnel range                               |

### Reaching HTTP tunnel URLs locally

HTTP tunnels use subdomains (e.g. `http://abc123.localhost:8080`).
Browsers do not resolve `*.localhost` by default. Two options:

**Option A — curl with a Host header (no setup)**

```bash theme={null}
curl -v -H "Host: abc123.localhost" http://localhost:8080/
```

**Option B — wildcard DNS via dnsmasq (macOS)**

```bash theme={null}
brew install dnsmasq
echo "address=/.localhost/127.0.0.1" | sudo tee -a $(brew --prefix)/etc/dnsmasq.conf
sudo brew services start dnsmasq
sudo mkdir -p /etc/resolver
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/localhost
```

Then visit `http://abc123.localhost:8080` in the browser.

***

## Scenario B — VPS production

Use this when you have a cloud server (Ubuntu 22.04 or later recommended) with a public IP address.

### Assumptions

| Item         | Example value                                          |
| ------------ | ------------------------------------------------------ |
| Domain       | `edge.rustunnel.com`                                   |
| Wildcard DNS | `*.edge.rustunnel.com → <server public IP>`            |
| TLS          | Let's Encrypt via Certbot + Cloudflare DNS challenge   |
| OS           | Ubuntu 22.04 LTS                                       |
| PostgreSQL   | Managed instance or self-hosted (required — see below) |

<Note>
  **PostgreSQL is required.** The server uses PostgreSQL to store API tokens and the tunnel audit log. You may run PostgreSQL on the same VPS or use a managed service (e.g. Supabase, Neon, AWS RDS, DigitalOcean Managed Databases). Set the connection URL in `deploy/server.toml` under `[database] url`.
</Note>

Set up the wildcard DNS record with your DNS provider before continuing.
Both `edge.rustunnel.com` (bare) and `*.edge.rustunnel.com` (wildcard) must resolve to your server IP — the wildcard is required so HTTP tunnel subdomains work.

### 1 — Install dependencies on the VPS

```bash theme={null}
apt update && apt install -y \
  git curl \
  certbot python3-certbot-dns-cloudflare \
  docker.io docker-compose-plugin

# Enable Docker to start on boot
systemctl enable --now docker
```

### 2 — Clone the repository

```bash theme={null}
git clone https://github.com/joaoh82/rustunnel.git
cd rustunnel
```

### 3 — Obtain TLS certificates

Create the Cloudflare credentials file:

```bash theme={null}
mkdir -p /etc/letsencrypt
cat > /etc/letsencrypt/cloudflare.ini <<'EOF'
# Cloudflare API token with DNS:Edit permission for the zone.
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 /etc/letsencrypt/cloudflare.ini
```

Request the certificate (bare domain + wildcard):

```bash theme={null}
certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "edge.rustunnel.com" \
  -d "*.edge.rustunnel.com" \
  --agree-tos \
  --email your@email.com
```

Certbot writes the PEM files to:

```text theme={null}
/etc/letsencrypt/live/edge.rustunnel.com/fullchain.pem
/etc/letsencrypt/live/edge.rustunnel.com/privkey.pem
```

Certbot installs a systemd timer for automatic renewal — no further action needed.

### 4 — Configure the server

Generate a strong admin token:

```bash theme={null}
openssl rand -hex 32
```

Edit `deploy/server.toml` — set **at minimum**:

```toml theme={null}
[server]
domain = "edge.rustunnel.com"   # ← your domain

[tls]
cert_path = "/etc/letsencrypt/live/edge.rustunnel.com/fullchain.pem"
key_path  = "/etc/letsencrypt/live/edge.rustunnel.com/privkey.pem"

[auth]
admin_token  = "PASTE_YOUR_GENERATED_TOKEN_HERE"
require_auth = true
```

The file is mounted read-only into the container.

### 5 — Grant the container access to the certificates

The container runs as a non-root user (`rustunnel`). Certbot sets restrictive permissions on the `live/` and `archive/` directories by default:

```bash theme={null}
# Allow read access to cert directories
chmod 755 /etc/letsencrypt/{live,archive}
chmod 640 /etc/letsencrypt/live/edge.rustunnel.com/*.pem
chmod 640 /etc/letsencrypt/archive/edge.rustunnel.com/*.pem
```

<Tip>If you prefer not to relax Certbot permissions, copy the certs to a dedicated directory and set up a Certbot post-hook to refresh the copies after each renewal.</Tip>

### 6 — Build the Docker image

```bash theme={null}
docker build -f deploy/Dockerfile -t rustunnel-server:latest .
```

### 7 — Start the server

```bash theme={null}
docker compose -f deploy/docker-compose.yml up -d
```

The `docker-compose.yml` mounts:

* `./server.toml` → `/etc/rustunnel/server.toml` (read-only)
* `/etc/letsencrypt` is **not** mounted by default — add the line below to the server service's `volumes` section before starting:

```yaml theme={null}
volumes:
  - ./server.toml:/etc/rustunnel/server.toml:ro
  - /etc/letsencrypt:/etc/letsencrypt:ro   # ← add this
  - rustunnel-data:/var/lib/rustunnel
```

### 8 — Open firewall ports

```bash theme={null}
ufw allow 80/tcp    comment "rustunnel HTTP edge"
ufw allow 443/tcp   comment "rustunnel HTTPS edge"
ufw allow 4040/tcp  comment "rustunnel control plane"
ufw allow 8443/tcp  comment "rustunnel dashboard"
ufw allow 9090/tcp  comment "rustunnel Prometheus metrics"
ufw allow 20000:20099/tcp comment "rustunnel TCP tunnels"
```

<Note>Port 9090 only needs to be open if you have an external Prometheus scraper. It is safe to leave it closed if you are running Prometheus on the same host (it reaches the metrics endpoint over the Docker bridge network).</Note>

### 9 — Verify the deployment

```bash theme={null}
# Health check
curl https://edge.rustunnel.com:8443/api/status

# Prometheus metrics
curl -s http://localhost:9090/metrics

# Tail logs
docker compose -f deploy/docker-compose.yml logs -f rustunnel-server
```

### 10 — Connect a client

```bash theme={null}
rustunnel http 3000 \
  --server edge.rustunnel.com:4040 \
  --token YOUR_ADMIN_TOKEN
```

### Port reference (production)

| Port          | Purpose                                                         |
| ------------- | --------------------------------------------------------------- |
| `80`          | HTTP edge (redirects to HTTPS; handles ACME HTTP-01 if enabled) |
| `443`         | HTTPS edge (TLS-terminated tunnel ingress)                      |
| `4040`        | Control-plane WebSocket — clients connect here                  |
| `8443`        | Dashboard UI and REST API                                       |
| `9090`        | Prometheus metrics (`/metrics`)                                 |
| `20000–20099` | TCP tunnel range (configurable via `tcp_port_range`)            |

***

## Optional: monitoring stack (Prometheus + Grafana)

Both compose files expose the metrics endpoint to the `rustunnel` Docker network.
The Prometheus service in `docker-compose.yml` scrapes it automatically.

```bash theme={null}
# Start server + Prometheus + Grafana
docker compose -f deploy/docker-compose.yml --profile monitoring up -d

# URLs
# Grafana:    http://<host>:3000   (admin / changeme — change GF_SECURITY_ADMIN_PASSWORD)
# Prometheus: http://<host>:9090
```

To change the Grafana admin password before starting, set the environment variable:

```bash theme={null}
export GRAFANA_PASSWORD="$(openssl rand -hex 16)"
docker compose -f deploy/docker-compose.yml --profile monitoring up -d
```

***

## Useful make targets

| Target                       | Description                         |
| ---------------------------- | ----------------------------------- |
| `make docker-build`          | Build the Docker image              |
| `make docker-run`            | Start the server container only     |
| `make docker-run-monitoring` | Start server + Prometheus + Grafana |
| `make docker-logs`           | Tail server container logs          |
| `make docker-stop`           | Stop and remove all containers      |

***

## Updating

Pull the latest code and rebuild:

```bash theme={null}
git pull
docker build -f deploy/Dockerfile -t rustunnel-server:latest .
docker compose -f deploy/docker-compose.yml up -d --force-recreate rustunnel-server
```

The `--force-recreate` flag restarts the container with the new image while
leaving the `rustunnel-data` volume (SQLite captured-request data) intact.
PostgreSQL data lives outside the container and is unaffected by container
updates.

***

## Troubleshooting

### Container exits immediately

```bash theme={null}
docker compose -f deploy/docker-compose.yml logs rustunnel-server
```

Common causes:

* **Config not mounted** — ensure `deploy/server.toml` exists and the volume path is correct.
* **Cert files not readable** — check permissions on `/etc/letsencrypt/` (see step 5).
* **Port already in use** — check `ss -tlnp | grep -E '80|443|4040|8443'`.

### Dashboard shows "dashboard assets not found"

The dashboard assets were not embedded at compile time. This happens if you built the Rust binary before running `npm run build` (or outside Docker). Rebuild the image with `docker build` — the multi-stage Dockerfile handles the UI build automatically.

### `--insecure` flag required even in production

This means the client is connecting to a server with a self-signed cert. Verify that the cert paths in `server.toml` point to the Let's Encrypt PEM files and that those files are accessible inside the container.

### Prometheus shows no data

Check that `deploy/prometheus.yml` targets `rustunnel-server:9090` and that both services are on the same Docker network (`rustunnel`). The metrics endpoint is not exposed on the host by default — Prometheus reaches it over the bridge network.

***

## Related pages

<CardGroup cols={2}>
  <Card title="Self-hosting" icon="server" href="/docs/guides/self-hosting">
    Deploy the server without Docker, using systemd, on a production VPS.
  </Card>

  <Card title="Architecture" icon="network" href="/docs/guides/architecture">
    The control plane, data plane, and subsystems running inside the container.
  </Card>

  <Card title="Client guide" icon="terminal" href="/docs/guides/client-guide">
    Configure the rustunnel CLI to connect to your Dockerized server.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/docs/quickstart">
    Open your first tunnel in under five minutes.
  </Card>
</CardGroup>
