# Docker Deployment Guide This guide covers 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. --- ## 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 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`. 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. --- ## 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 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 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 docker compose -f deploy/docker-compose.local.yml up ``` Add `-d` to detach: ```bash docker compose -f deploy/docker-compose.local.yml up -d ``` ### 4 — Verify it is running ```bash # 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 # Expose a local service running on port 3000 rustunnel http 3000 \ --server localhost:4040 \ --token dev-secret-change-me \ --insecure ``` > `--insecure` skips TLS verification. Required for self-signed certificates. > **Never** use this flag against a production server. ### 6 — Stop the server ```bash 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 curl -v -H "Host: abc123.localhost" http://localhost:8080/ ``` **Option B — wildcard DNS via dnsmasq (macOS)** ```bash 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 → ` | | TLS | Let's Encrypt via Certbot + Cloudflare DNS challenge | | OS | Ubuntu 22.04 LTS | | PostgreSQL | Managed instance or self-hosted (required — see below) | > **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`. 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 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 git clone https://github.com/joaoh82/rustunnel.git cd rustunnel ``` ### 3 — Obtain TLS certificates Create the Cloudflare credentials file: ```bash 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 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: ``` /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 openssl rand -hex 32 ``` Edit `deploy/server.toml` — set **at minimum**: ```toml [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 # 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 ``` > **Alternative**: 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. ### 6 — Build the Docker image ```bash docker build -f deploy/Dockerfile -t rustunnel-server:latest . ``` ### 7 — Start the server ```bash 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 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 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" ``` > 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). ### 9 — Verify the deployment ```bash # 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 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 # Start server + Prometheus + Grafana docker compose -f deploy/docker-compose.yml --profile monitoring up -d # URLs # Grafana: http://:3000 (admin / changeme — change GF_SECURITY_ADMIN_PASSWORD) # Prometheus: http://:9090 ``` To change the Grafana admin password before starting, set the environment variable: ```bash 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 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 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.