# Advanced Tunnel Configuration

<EnterpriseFeature name="Secure tunneling" />

The [`zuplo/tunnel`](https://hub.docker.com/r/zuplo/tunnel) image covers most
deployments without configuration beyond `TUNNEL_TOKEN`. This page is for teams
that need more control: pinning versions, tuning the transport, rotating tokens,
or building and running the connector image themselves.

## The cloudflared foundation

Zuplo's tunnel is a thin packaging of
[cloudflared](https://github.com/cloudflare/cloudflared), Cloudflare's open
source tunnel connector. It isn't a Zuplo-specific protocol or a proprietary
agent. The entire `zuplo/tunnel` image is the upstream
[`cloudflare/cloudflared`](https://hub.docker.com/r/cloudflare/cloudflared)
image with a fixed entrypoint:

```dockerfile title="Dockerfile"
FROM cloudflare/cloudflared:2026.7.3-amd64
ENTRYPOINT ["cloudflared", "tunnel", "--no-autoupdate", "--protocol", "http2", "run"]
```

Two consequences follow from this. First, everything the
[cloudflared documentation](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/)
says about deploying, monitoring, and tuning a connector applies to your Zuplo
tunnel. Second, you can run the upstream image yourself instead of Zuplo's — see
[Run your own connector image](#run-your-own-connector-image).

### Entrypoint flags

| Flag               | Why the image sets it                                                                                                                                                                                                                        |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--no-autoupdate`  | Stops cloudflared from replacing its own binary while running. The image tag is then the only thing that determines the version, which keeps containers immutable and deployments reproducible.                                              |
| `--protocol http2` | Forces the HTTP/2 transport. The cloudflared default, `auto`, prefers QUIC over UDP port 7844 and falls back to HTTP/2 when it can't open UDP connections. Many corporate networks block outbound UDP, so pinning HTTP/2 skips the fallback. |
| `run`              | Runs a remotely-managed tunnel. The tunnel's routing configuration lives in Zuplo, not in a local config file, and cloudflared reads its credentials from `TUNNEL_TOKEN`.                                                                    |

### Container characteristics

Because the image inherits the upstream cloudflared base, it has properties
worth knowing when you write a deployment manifest or a security policy:

- **Distroless base, no shell.** You can't exec into a running tunnel container
  to debug it. Run a separate debug container in the same network namespace
  instead — see
  [Check connectivity to your backend](./tunnel-troubleshooting.mdx#check-connectivity-to-your-backend).
- **Runs as a non-root user** (UID 65532). It needs no elevated privileges, no
  additional Linux capabilities, and no writable root filesystem.
- **Needs no persistent volume.** The tunnel holds no state between restarts and
  fetches its configuration from Zuplo on startup.
- **Listens on no inbound ports** unless you set `TUNNEL_METRICS`.

## Environment variables

The tunnel reads cloudflared's environment variables. The ones that matter for a
Zuplo tunnel:

| Variable                    | Default | Description                                                                                            |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `TUNNEL_TOKEN`              | none    | The tunnel's connection token, from `zuplo tunnel describe`. Required.                                 |
| `TUNNEL_LOGLEVEL`           | `info`  | Log verbosity. One of `debug`, `info`, `warn`, `error`, or `fatal`. Use `debug` when troubleshooting.  |
| `TUNNEL_METRICS`            | none    | Address to serve Prometheus metrics on, such as `0.0.0.0:2000`. Useful for scraping connection health. |
| `TUNNEL_TRANSPORT_PROTOCOL` | `auto`  | Transport to Cloudflare's edge. One of `auto`, `http2`, or `quic`.                                     |
| `NO_AUTOUPDATE`             | `false` | Disables in-place binary updates.                                                                      |
| `TUNNEL_EDGE_IP_VERSION`    | `4`     | IP version for reaching the edge. One of `4`, `6`, or `auto`.                                          |

:::caution{title="Flags override environment variables"}

The `zuplo/tunnel` image sets `--no-autoupdate` and `--protocol http2` as
command-line flags in its entrypoint, and cloudflared gives command-line flags
precedence over environment variables. Setting `NO_AUTOUPDATE` or
`TUNNEL_TRANSPORT_PROTOCOL` on a `zuplo/tunnel` container has no effect. To
change either one, build your own image with a different entrypoint.

:::

## Pin and upgrade the image version

The `zuplo/tunnel` tags mirror the cloudflared release they wrap, so
`zuplo/tunnel:2025.10.1` contains cloudflared 2025.10.1. The `latest` tag always
points at the most recent release.

For production, pin an explicit tag rather than using `latest`, so a redeploy
can't silently change connector versions:

```bash
docker run -d --name zuplo-tunnel \
  -e TUNNEL_TOKEN=<YOUR_TUNNEL_TOKEN> \
  zuplo/tunnel:2025.10.1
```

Then upgrade deliberately. Cloudflare supports cloudflared versions released
within one year of the most recent release, and may introduce breaking changes
that affect older versions. Check for a newer tag on a regular cadence and treat
the upgrade as routine maintenance. Test each new version in a staging
environment before rolling it out to production.

Because tunnel instances that share a token act as replicas, you can upgrade
without downtime: start instances on the new version, wait for them to connect,
then stop the old ones.

## Rotate the tunnel token

Rotate a token on a schedule, and immediately if you suspect it's been exposed.
With at least two instances running, you can rotate without dropping traffic.

<Stepper>

1. Issue a new token for the tunnel:

   ```bash
   zuplo tunnel rotate-token --tunnel-id tnl_TRMZwunq2PLNQDwhu6A04Bmx
   ```

   After rotation, the old token can't establish _new_ connections, but
   instances already connected with it keep serving traffic.

1. Update the token in your secret manager.

1. Restart half your instances so they pick up the new token, and wait for them
   to report as connected.

1. Restart the remaining instances.

</Stepper>

Rotating with a single instance running causes a brief interruption while the
instance restarts. Schedule it during a maintenance window.

## Run your own connector image

Because the tunnel is stock cloudflared, you can build and run the connector
yourself. Consider this when you need to:

- Change the entrypoint flags, such as running QUIC instead of HTTP/2, or
  exposing the metrics endpoint.
- Mirror the image into your own registry to satisfy a policy that forbids
  pulling from public registries.
- Meet a scanning, signing, or base-image requirement your platform enforces.
- Run on `arm64`, using a matching upstream tag.

To match the behavior of `zuplo/tunnel`, build from the upstream image and
replicate the entrypoint:

```dockerfile title="Dockerfile"
FROM cloudflare/cloudflared:2026.7.3-amd64
ENTRYPOINT ["cloudflared", "tunnel", "--no-autoupdate", "--protocol", "http2", "run"]
```

Replace `2026.7.3` with a current tag from
[Docker Hub](https://hub.docker.com/r/cloudflare/cloudflared/tags) — a version
pinned here goes stale, and running a connector more than a year behind the
latest release falls outside Cloudflare's support window. Use the `-amd64` or
`-arm64` suffix that matches your host architecture.

Build and run it with the same `TUNNEL_TOKEN` you'd give the Zuplo image:

```bash
docker build -t my-registry/tunnel:2026.7.3 .
docker run -d --name tunnel \
  -e TUNNEL_TOKEN=<YOUR_TUNNEL_TOKEN> \
  my-registry/tunnel:2026.7.3
```

You can also run the upstream image directly, without a Dockerfile, by supplying
the command yourself:

```bash
docker run -d --name tunnel \
  -e TUNNEL_TOKEN=<YOUR_TUNNEL_TOKEN> \
  cloudflare/cloudflared:2026.7.3-amd64 \
  tunnel --no-autoupdate --protocol http2 run
```

:::caution

Keep the `run` subcommand and don't add a local configuration file, credentials
file, or `--url` argument. Your tunnel is remotely managed: Zuplo owns the
routing configuration, and cloudflared fetches it using the token. Local
configuration conflicts with that and can stop traffic reaching your services.

:::

:::note

Zuplo tests and supports the `zuplo/tunnel` image. A connector image you build
and operate yourself is yours to maintain, and support can help with the tunnel
and its configuration in Zuplo but not with your image or build pipeline. If
you're unsure whether a change is safe, ask
[Zuplo support](mailto:support@zuplo.com) before rolling it out.

:::

## Next steps

- [Troubleshoot a tunnel](./tunnel-troubleshooting.mdx) — diagnose a tunnel
  that's down or not passing traffic.
- [Connect to tunnel services](./tunnel-services.mdx) — the service
  configuration reference and `service://` usage.
