# qURL Tunnel Sidecar Install Guide

<!-- Keep this Markdown guide aligned with app/docs/tunnels/page.tsx. -->

> Install the qURL reverse tunnel sidecar safely with Slack, Docker, Docker Compose, ECS/Fargate, or Kubernetes.

Source: https://layerv.ai/docs/tunnels/

---

## What This Is

Use the qURL tunnel sidecar when the target service is local, private, or not directly routable by URL. Slack creates the tunnel and one-time bootstrap key. The sidecar runs beside your app, connects out through LayerV, and lets users mint qURLs with:

```text
/qurl get $prod-dashboard
```

The website should never ask you to paste a live bootstrap key. Use Slack to mint secrets; use this guide to understand and operate the install.

## The Happy Path

1. A Slack admin runs the guided installer:

```text
/qurl tunnel install
```

2. Slack asks for:

- Tunnel ID, for example `prod-dashboard`
- Optional channel alias, for example `$prod`
- Local HTTP port, usually `8080`
- Target runtime: Docker, Docker Compose, ECS/Fargate, or Kubernetes
- Docker container name or Compose service name when applicable

3. Slack returns:

- A one-hour tunnel bootstrap key
- A runtime-specific install block
- A per-tunnel `qurl-proxy.yaml`
- Verification instructions

4. Run the generated block as a unit on the host or platform that runs the app.

5. After the sidecar logs show a successful connection, remove the bootstrap key from the runtime path. Keep the agent-state directory, volume, or PVC.

## Typed Command Form

The guided modal is the best default. The typed command is useful for repeatable setup or sandbox work:

```text
/qurl tunnel install <tunnel-id|$tunnel-id> [env:docker|docker-compose|ecs-fargate|kubernetes] [port:8080] [alias:$alias] [container:<name>|service:<name>]
```

Examples:

```text
/qurl tunnel install prod-dashboard env:docker port:8080 container:web
/qurl tunnel install prod-dashboard env:docker-compose port:8080 service:web
/qurl tunnel install prod-dashboard env:ecs-fargate port:8080
/qurl tunnel install prod-dashboard env:kubernetes port:8080
```

Notes:

- `env:docker` is the default.
- `env:compose` is accepted as shorthand for `env:docker-compose`.
- `container:<name>` is for Docker container installs.
- `service:<name>` is for Docker Compose installs.
- Prefixing the ID with `$` is accepted and normalized.
- If `alias:` is omitted, the Slack channel alias uses the ID.

## Key Concepts

### Tunnel ID

The stable customer-facing identifier for one tunnel inside your account, such as `prod-dashboard`.

Use the same ID for replicas of the same service. Do not reuse an ID for a different service. Two sidecars with the same ID are treated as replicas of one tunnel.

### Alias

The Slack handle users type later, such as `$prod`.

The alias can change. The ID should not. If no alias is supplied, Slack uses the ID.

### Bootstrap Key

A short-lived key used for first registration or explicit state recovery. It is not steady-state tunnel authentication.

Slack currently mints tunnel bootstrap keys for about one hour and shows the exact expiration in the message.

### Agent State

The persistent state directory stores the sidecar identity:

- X25519 key material
- `agent_id`
- NHP peer config
- Tunnel identity cache

With the default `file` provider, the state directory stores plaintext key material in `private_key` and `etc/config.toml`. With `aws-kms` or `gcp-kms`, durable state stores `private_key.sealed.json` instead and the client materializes OpenNHP's plaintext runtime config only under a process-lifetime runtime directory.

Treat backups of this directory as secret-bearing. Do not delete it after first start unless you intentionally want to recover with a fresh bootstrap key.

### Route Config

The generated `qurl-proxy.yaml` tells the sidecar which local service to expose:

```yaml
routes:
  - type: http
    local_ip: 127.0.0.1
    local_port: 8080
```

For the standard Slack-generated single-route flow, the tunnel ID is supplied by the runtime environment as `QURL_TUNNEL_ID`. The generated route file intentionally omits a `name` field; tunnel identity belongs in the runtime environment, not `qurl-proxy.yaml`.

Standard qURL tunnel installs do not put LayerV routing addresses in customer YAML. Do not add `server.addr`, `connect.layerv`, `proxy.layerv`, `frps-*.internal`, or a public tunnel port to this file.

### Runtime

The deployment target, such as Docker, Docker Compose, ECS/Fargate, or Kubernetes. The runtime controls how the sidecar reaches `localhost` and where durable agent state lives.

## First Start vs Restart

### First Start

On first start, the sidecar:

1. Reads the bootstrap key from `QURL_API_KEY_FILE` or the runtime secret mechanism.
2. Registers or verifies the persistent NHP keypair.
3. Resolves the tunnel ID to the customer-owned tunnel resource.
4. Caches the tunnel identity in the mounted agent-state directory.
5. Performs an NHP knock.
6. Receives the public tunnel address from the NHP ACK.
7. Connects to the reverse tunnel server and exposes the configured local route.

### Normal Restart

On restart, the sidecar should use the mounted state directory. The bootstrap key should already be removed.

If both the bootstrap key and state are missing, the sidecar fails closed. Run the Slack install flow again to mint a fresh bootstrap key.

## Docker Sidecar

Best for a web app already running in one Docker container on a Linux host.

Slack command:

```text
/qurl tunnel install prod-dashboard env:docker port:8080 container:web
```

What the generated block does:

```sh
QURL_TUNNEL_ID='prod-dashboard'
WEB_CONTAINER='web'
TUNNEL_CONTAINER="qurl-tunnel-${QURL_TUNNEL_ID}"
SECRET_DIR="/run/secrets/qurl-tunnel/${QURL_TUNNEL_ID}"
AGENT_STATE_DIR="/var/lib/layerv/qurl-tunnel/${QURL_TUNNEL_ID}/agent"
CONFIG_FILE="$PWD/qurl-proxy-${QURL_TUNNEL_ID}.yaml"

# The Slack block prompts for the bootstrap key with hidden input.
# Do not paste the key into the shell script itself.
# Replace vX.Y.Z with the immutable image tag shown in the Slack output.

docker run -d \
  --name "$TUNNEL_CONTAINER" \
  --network "container:${WEB_CONTAINER}" \
  --restart=on-failure:5 \
  -v "$AGENT_STATE_DIR:/var/lib/layerv/agent" \
  -v "$SECRET_DIR:$SECRET_DIR:ro" \
  -v "$CONFIG_FILE:/work/qurl-proxy.yaml:ro" \
  -e QURL_API_KEY_FILE="$SECRET_DIR/api_key" \
  -e QURL_TUNNEL_ID="$QURL_TUNNEL_ID" \
  ghcr.io/layervai/qurl-reverse-tunnel-client:vX.Y.Z
```

Verify:

```sh
docker logs -f qurl-tunnel-prod-dashboard
```

After the logs show a successful tunnel connection:

```sh
sudo rm -f /run/secrets/qurl-tunnel/prod-dashboard/api_key
```

Keep:

```sh
/var/lib/layerv/qurl-tunnel/prod-dashboard/agent
```

That directory stores the sidecar identity.

## Docker Compose

Best for an app already described by `compose.yaml`.

Slack command:

```text
/qurl tunnel install prod-dashboard env:docker-compose port:8080 service:web
```

The generated fragment uses:

```yaml
network_mode: "service:web"
```

That makes `127.0.0.1:8080` inside the qURL sidecar reach the app service in the shared network namespace.

Run from the Compose project directory. If your app file is not `compose.yaml`, set `APP_COMPOSE_FILE` before running the generated block.

Do not hand-edit the generated tunnel fragment. Re-run the Slack install to regenerate it when the ID, port, image, or service changes.

## AWS ECS/Fargate

Best for an ECS task with `awsvpc` networking and durable storage for sidecar state.

Slack command:

```text
/qurl tunnel install prod-dashboard env:ecs-fargate port:8080
```

Use the Slack output as a task-definition checklist:

- Put the qURL sidecar in the same task definition as the target container.
- Use the same task network namespace; `127.0.0.1:<port>` reaches sibling containers in `awsvpc`.
- Store the bootstrap key in AWS Secrets Manager for first launch.
- Mount durable EFS-backed state at `/var/lib/layerv/agent`.
- Mount the route config at `/work/qurl-proxy.yaml`.
- Do not share one `qurl-agent-state` volume across concurrently running sidecars.
- Delete the bootstrap secret after the task logs show a successful tunnel connection.

ECS native task secrets inject as environment variables. Docker, Docker Compose, and Kubernetes should prefer `QURL_API_KEY_FILE`.

## Kubernetes

Best for a same-Pod sidecar in GKE, EKS, or another Kubernetes cluster.

Slack command:

```text
/qurl tunnel install prod-dashboard env:kubernetes port:8080
```

Use the Slack output to create:

- A Secret for the bootstrap key
- A ConfigMap for `qurl-proxy.yaml`
- A PVC for agent state
- A `qurl-tunnel` container in the same Pod as the app container

Guidance:

- Put the sidecar in the same Pod as the app container.
- Use one PVC per sidecar replica.
- For multiple replicas, prefer a StatefulSet with `volumeClaimTemplates`.
- Do not share one writable agent-state PVC across concurrently running sidecars.
- Delete the bootstrap Secret after the Pod logs show a successful tunnel connection.

## Ready Checklist

The tunnel is ready for users when all of these are true:

- The local service responds from inside the sidecar network namespace.
- The sidecar logs show a successful tunnel connection.
- The bootstrap key file, Secret, or platform secret has been removed from the runtime path.
- A Slack user can run `/qurl get $prod-dashboard` and reach the service.

## Security Rules

- Do not paste the bootstrap key into a saved shell script.
- Do not leave the bootstrap key mounted after first successful start.
- Do not delete the agent-state directory after first successful start.
- Do not share one agent-state volume across concurrently running sidecars.
- Do not put internal LayerV hostnames in customer YAML.
- Do not reuse an ID for different services.
- Treat agent-state backups as secret-bearing.

## Optional KMS Key Storage

The default `file` provider is the simplest install path. It stores the sidecar private key in the mounted agent-state directory with mode `0600`. Enterprise host-mode installs that need the state volume to survive instance replacement without durable plaintext key files can select a sealed provider. Use `aws-kms` or `gcp-kms` for host-mode KMS sealing. Use `aws-nitro` or `gcp-confidential-space` when the customer security owner requires cloud attestation before key release.

```sh
# AWS KMS
-e LAYERV_KEY_PROVIDER=aws-kms \
-e LAYERV_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/1234abcd-... \
-e LAYERV_AWS_KMS_REGION=us-east-1

# GCP Cloud KMS
-e LAYERV_KEY_PROVIDER=gcp-kms \
-e LAYERV_GCP_KMS_KEY_NAME=projects/acme-prod/locations/us/keyRings/qurl/cryptoKeys/agent-identity

# Optional sealed-provider runtime placement. Use tmpfs.
-e LAYERV_NHP_RUNTIME_DIR=/dev/shm

# Existing file-provider volume migration, one restart only:
-e LAYERV_ALLOW_KEY_MIGRATION=true
```

With `aws-kms`, `gcp-kms`, `aws-nitro`, or `gcp-confidential-space`, the durable state directory contains `private_key.sealed.json` instead of persistent plaintext `private_key` and `etc/config.toml`. Startup decrypts the sealed blob with cloud KMS, writes OpenNHP's required plaintext runtime config under tmpfs, and removes that runtime directory on clean shutdown.

Attested provider env shape:

```sh
# AWS Nitro attested KMS release
-e LAYERV_KEY_PROVIDER=aws-nitro \
-e LAYERV_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/1234abcd-... \
-e LAYERV_AWS_KMS_REGION=us-east-1 \
-e LAYERV_AWS_NITRO_ATTESTATION_DOCUMENT_FILE=/run/qurl/attestation.cose \
-e LAYERV_AWS_NITRO_ATTESTATION_DOCUMENT_ENCODING=raw \
-e LAYERV_AWS_NITRO_RECIPIENT_UNWRAP_COMMAND=/opt/qurl/bin/unwrap-recipient

# GCP Confidential Space attested KMS release
-e LAYERV_KEY_PROVIDER=gcp-confidential-space \
-e LAYERV_GCP_KMS_KEY_NAME=projects/acme-prod/locations/us/keyRings/qurl/cryptoKeys/agent-identity \
-e LAYERV_GCP_CONFIDENTIAL_SPACE_TOKEN_FILE=/run/container_launcher/attestation_verifier_claims_token \
-e LAYERV_GCP_CONFIDENTIAL_SPACE_IMAGE_DIGEST=sha256:... \
-e LAYERV_GCP_CONFIDENTIAL_SPACE_SERVICE_ACCOUNT=qurl-agent@acme-prod.iam.gserviceaccount.com
```

KMS rollout rules:

- Keep `/dev/shm` available, or set `LAYERV_NHP_RUNTIME_DIR` to another tmpfs-backed path. Do not use disk-backed runtime dirs in production.
- Grant the workload principal both encrypt and decrypt permission before first boot; the client verifies decryptability before removing plaintext state.
- To convert an existing file-provider state volume, set `LAYERV_ALLOW_KEY_MIGRATION=true` for the migration restart only, then remove it after `private_key.sealed.json` is written.
- Treat migration as one-way for that agent identity. The sealed blob and cloud KMS key must be backed up and recoverable together.
- Warm restarts need live KMS decrypt permission, so KMS and IAM reachability become part of the tunnel startup SLO.
- KMS sealing protects copied state volumes that lack IAM decrypt rights; it is not a host-compromise boundary. For the optional advanced attested tier, use `aws-nitro` or `gcp-confidential-space` and validate cloud policy negative tests.

## Optional Advanced Attested Key-Provider Onboarding

Standard tunnel onboarding stays simple. Use the advanced customer onboarding guide only when a qURL tunnel customer wants AWS Nitro or GCP Confidential Space to gate release of the sidecar private key:

- Website: https://layerv.ai/docs/tunnels/attested-key-providers/
- Markdown: https://layerv.ai/docs/tunnels-attested-key-providers.md

The optional guide covers provider choice, required customer inputs, AWS KMS Recipient policy shape, GCP Workload Identity Federation and Cloud KMS policy shape, bootstrap, migration, validation evidence, operations handoff, and troubleshooting.

## Troubleshooting

### The shell block asks for a web container or service name.

Replace the placeholder with the Docker container name or Compose service name. You can also re-run the Slack command with `container:web` or `service:web`.

### The terminal echoes pasted input.

Stop and use your platform secret manager instead. The bootstrap key should not land in terminal scrollback, shell history, recorded sessions, or saved scripts.

### The container exits with no config found.

Make sure `qurl-proxy.yaml` exists before the bind mount. Docker creates missing host paths as directories, which makes the mounted config unreadable.

### The container cannot write `agent_id` or `private_key`.

The sidecar runs as UID/GID `65532`. Create the state directory with mode `0700` and owner `65532:65532` before first start.

### A revoked ID refuses to start.

That is intentional fail-stop behavior. Pick a fresh ID or intentionally delete/recreate the tunnel resource instead of silently rolling over.

### The API key was removed and the state volume was deleted.

Run the Slack install flow again to mint a fresh bootstrap key. Without state or a bootstrap key, the client fails closed.

## Glossary

- **ID:** Stable tunnel identity inside the customer account.
- **Alias:** Slack handle users type, such as `$prod`.
- **Bootstrap key:** One-hour key for first registration or state recovery.
- **Agent state:** Persistent sidecar identity and tunnel cache.
- **Route config:** Local service mapping in `qurl-proxy.yaml`.
- **NHP ACK:** NHP response that tells the sidecar which public tunnel address to dial at runtime.

---

*This markdown version is pre-authored for developer and AI-agent consumption. For the full interactive experience, visit https://layerv.ai/docs/tunnels/.*
