Running Route6 in a Container
The container runs the same daemon as the bare-binary install: same MCP endpoint, same identity, same tools. Docker here is an install environment, not a connection method — most people take the binary, and this page is for environments that require a container.
A container is not a machine. Inbound port forwards have to be told which machine
answers them — your host, or a sibling container. That is the one thing this page exists
to explain. Get it wrong and the forward is created and looks healthy, but visitors hang
for about ten seconds and then get nothing. The daemon tells you exactly why: docker logs r6me names the target it could not reach. See Troubleshooting.
The base compose file
Get your API key
From the dashboard (opens in a new tab) under Agents. It starts with sk_a6_.
Save a docker-compose.yml
services:
r6me:
image: route6me/netid:1.2.4
container_name: r6me
restart: unless-stopped
environment:
ROUTE6_API_KEY: "${ROUTE6_API_KEY:?set ROUTE6_API_KEY in .env}"
ports:
- "127.0.0.1:3000:3000" # MCP — point your agent here
- "127.0.0.1:3001:3001" # status API — `curl localhost:3001/status`
- "127.0.0.1:1080:1080" # egress proxy — SOCKS5 + HTTP CONNECT + plain HTTP
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- r6me-state:/var/lib/r6me
volumes:
r6me-state:Put your key in a .env file beside it:
echo 'ROUTE6_API_KEY=sk_a6_your_key_here' > .envStart it
docker compose up -d
curl -s localhost:3001/statusYou want "transport": "connected" and your agent_id.
The 127.0.0.1: prefixes are the line that decides exposure. Dropping them (plain
"3000:3000") publishes MCP on every interface of the host. The MCP endpoint carries
your agent's identity — anyone who can reach it acts as your agent — so only widen it
deliberately, and never onto a public interface.
The volumes: entry is not optional either. It persists the agent's WireGuard key and
applied config generation, so a restart resumes. Without it the agent re-registers
every time the container restarts.
Connect your agent to MCP
Point any MCP client at http://localhost:3000/mcp. For example, Claude Desktop:
{
"mcpServers": {
"route6": { "url": "http://localhost:3000/mcp" }
}
}No API key here — the daemon already holds it.
Outbound: sending traffic from your agent's IP
Port 1080 is a proxy speaking SOCKS5, HTTP CONNECT and plain HTTP on one port. Anything
you point at it leaves from your agent's own public address:
export http_proxy=http://127.0.0.1:1080
export https_proxy=http://127.0.0.1:1080
curl https://api64.ipify.org # your agent's IPv6IPv4-only destinations work too — the hub reaches them over NAT64.
Use lowercase http_proxy with curl. libcurl deliberately ignores an uppercase
HTTP_PROXY (it could arrive from a CGI Proxy: header) while honouring HTTPS_PROXY
either way. So uppercase-only looks like it works — https tunnels fine — and sends plain
http straight out your normal egress instead. Most other clients (python requests, node,
Go) accept either case.
For tools that speak SOCKS: ALL_PROXY=socks5h://127.0.0.1:1080.
Inbound: which machine answers a forward?
When you call port_forward { action: "create" }, Route6 accepts the connection on your public IPv6 at
the gateway and relays it to your daemon. The daemon then has to connect to whatever is
actually serving — and "localhost" inside a container means the container, where nothing is
listening.
There are two topologies. They compose freely; you can use both at once.
Topology A — the service runs on your host
This is what extra_hosts: ["host.docker.internal:host-gateway"] is for. With it present,
the daemon rewrites loopback forward targets to the host gateway address, so a forward
whose target is localhost reaches your host.
# your service, on the host
python3 -m http.server 8080 --bind 0.0.0.0Then, from your agent:
port_forward (action: create) { external_port: 28080, internal_port: 8080 }The one that catches everyone: host.docker.internal resolves to your host's
bridge address — typically 172.17.0.1 — not its loopback. A service bound to
127.0.0.1 on the host is not reachable from the container, so the forward is created
successfully and then fails on every connection. Bind your service to 0.0.0.0 (or to the
bridge address). Note the --bind 0.0.0.0 above — it is doing real work.
Verified end to end: with one service on 0.0.0.0:8500 and another on 127.0.0.1:8600,
on the same agent at the same moment, the first answered from the internet and the second
produced this in docker logs r6me:
forwards: dial target host.docker.internal:8600 failed:
dial tcp 172.17.0.1:8600: connect: connection refused (hub will RST the visitor)If extra_hosts is missing, the daemon says so at startup and leaves targets untranslated —
your forwards then resolve inside the container, where nothing is listening:
container mode: no host gateway discoverable — loopback forward targets will NOT be
translated. Add extra_hosts: ["host.docker.internal:host-gateway"] to your compose,
or set R6ME_HOST_GATEWAY.Topology B — the service runs in a sibling container
Use R6ME_FORWARD_TARGETS to say which container answers which forward. Each service keeps
its own network — nothing gives up its network namespace, and nothing is published to the
host.
services:
r6me:
image: route6me/netid:1.2.4
container_name: r6me
restart: unless-stopped
environment:
ROUTE6_API_KEY: "${ROUTE6_API_KEY:?}"
# forward target port -> the container that answers it
R6ME_FORWARD_TARGETS: "8080=api,9000=worker:3000"
extra_hosts:
- "host.docker.internal:host-gateway" # any UNLISTED port still goes to the host
volumes:
- r6me-state:/var/lib/r6me
ports:
- "127.0.0.1:3000:3000"
networks: [appnet]
api:
image: your/api # listening on 8080
networks: [appnet]
environment: # and its egress leaves from the agent's own address too
http_proxy: "http://r6me:1080"
https_proxy: "http://r6me:1080"
worker:
image: your/worker # listening on 3000, reached as forward port 9000
networks: [appnet]
networks:
appnet:
volumes:
r6me-state:The syntax is "<port>=<host>[:<port>]", comma-separated:
| Entry | Meaning |
|---|---|
8080=api | forward target port 8080 → container api, same port |
9000=worker:3000 | forward target port 9000 → container worker port 3000 |
5432=127.0.0.1 | opt port 5432 out of host translation (stay inside the container) |
Ports you do not list still resolve to the host, so host and sibling services mix freely.
An IPv6 literal must be bracketed (8080=[fd00::2]:8080), the same rule URLs use.
A malformed entry is refused and logged at startup rather than guessed at — a wrong
target is worse than a missing one, because it connects to the wrong service. Valid
entries are echoed too, so docker logs r6me tells you exactly what the daemon believes:
R6ME_FORWARD_TARGETS: ignoring entry "notaport=x": "notaport" is not a port in 1-65535
forward target: port 8080 -> api
forward target: port 9000 -> worker:3000Give each service a different internal_port. The map is keyed by the forward's
target port, because that is the only per-forward identifier the daemon receives. Two
sibling services both listening on 8080 cannot be told apart, however different their
external ports are.
The older R6ME_HOST_GATEWAY=<service name> still works, but it sends every loopback
forward to that one destination, so it cannot serve host and sibling at once. Prefer the
map.
Troubleshooting forwards
The failure mode worth naming: the forward is created and looks healthy, but every visitor hangs for about ten seconds and gets nothing. Nothing is wrong on the Route6 side — the daemon tried to reach a target that does not exist from inside the container.
Read the daemon log first — it names the cause
docker logs r6me 2>&1 | grep 'forwards: dial target'This is the fastest answer you will get, because the daemon prints the address it actually dialled and what went wrong:
forwards: dial target host.docker.internal:8600 failed:
dial tcp 172.17.0.1:8600: connect: connection refused (hub will RST the visitor)That line tells you the topology question is already answered — it resolved to the host at
172.17.0.1 — and that the service there is simply not accepting. Only if there is no such
line do you need the rest of this list.
Is the forward registered with the daemon?
curl -s localhost:3001/status | grep -A6 forwardsIf it is not listed, the problem is upstream of the container — check that
port_forward { action: "create" } returned an endpoint.
status echoes the target you asked for, so a forward aimed at the host shows
"target_host": "127.0.0.1" even though the daemon translates it. That is expected; the
log line above shows what was really dialled.
Did you just create it?
A new forward takes a few seconds to reach the gateway. A connection refused immediately
after port_forward { action: "create" }, which then succeeds on retry, is propagation and not a fault.
Is your service reachable from inside the container's network?
The image has no shell, so test from a throwaway container on the same network:
# Topology A — the host
docker run --rm --add-host host.docker.internal:host-gateway curlimages/curl \
-sS -m 5 http://host.docker.internal:8080/
# Topology B — a sibling
docker run --rm --network <your_compose>_appnet curlimages/curl \
-sS -m 5 http://api:8080/If that fails, Route6 is not involved yet — it is Docker networking.
Is your host service bound to 0.0.0.0?
ss -ltn | grep 8080127.0.0.1:8080 will not work (see the warning above). You want 0.0.0.0:8080 or the
bridge address.
Is the target map what you think it is?
docker logs r6me 2>&1 | grep -E 'forward target|FORWARD_TARGETS'Valid entries are echoed at startup and rejected ones are named, so this prints the map the daemon is actually using:
forward target: port 8080 -> api
forward target: port 9000 -> worker:3000
R6ME_FORWARD_TARGETS: ignoring entry "notaport=x": "notaport" is not a port in 1-65535Other things worth knowing
There is no shell in the image. docker exec has nothing to run. Inspect the agent
from the host instead:
curl -s localhost:3001/status # config generation, wg health, forwards, transport
docker logs r6meNothing listens on your public address. The container binds only container-local addresses; MCP, the status API and the proxy are reachable solely through the port mappings above. A port scan of your agent's public address shows only the forwards you deliberately created.
Upgrading. Bump the tag, then:
docker compose pull && docker compose up -dThe state volume means the agent resumes on the same identity — same WireGuard key, same config generation, no re-registration.