Sign inSign up

earcandy/warp-wireproxy

By earcandy

Updated 4 days ago

Cloudflare WARP as a SOCKS5/HTTP proxy over userspace WireGuard. ~17MB, no NET_ADMIN, no root.

Image
0

4.2K

earcandy/warp-wireproxy repository overview

warp-wireproxy

build watch-upstream License: MIT

Cloudflare WARP as a SOCKS5 / HTTP proxy, in an ~17 MB container. Userspace WireGuard — no NET_ADMIN, no SYS_MODULE, no /dev/net/tun, no /lib/modules, no privileged, no sysctls, and no root.

Built on wireproxy, which speaks WireGuard entirely in userspace and exposes it as a proxy. That is the whole trick: there is no network interface to create, so there is nothing to grant the container.

docker run -d --name warp -p 1080:1080 ghcr.io/nkg/warp-wireproxy

curl -x socks5h://127.0.0.1:1080 https://www.cloudflare.com/cdn-cgi/trace
# ...
# warp=on

Also on Docker Hub as earcandy/warp-wireproxy.

Contents

Why

Most WARP containers either ship Cloudflare's official warp-cli (which needs a Debian/Ubuntu base, dbus, a daemon, and sudo) or route the whole container network through a kernel WireGuard interface (which needs NET_ADMIN, SYS_MODULE, and a matching host kernel module).

This one does neither.

warp-wireproxywarp-cli imageskernel-WireGuard images
Image size~17 MB~500 MB~30 MB
Root requirednoyes (sudo)yes
Extra capabilitiesnonenoneNET_ADMIN, SYS_MODULE
Host kernel modulenonoyes
Shell / curl / jq in imageshell onlyyesusually
Architectures8 (10 shell-less)amd64, arm64varies

Quick start

Docker
docker run -d --name warp \
    --restart unless-stopped \
    -p 1080:1080 \
    -p 8080:8080 \
    -v warp-state:/var/lib/warp-wireproxy \
    ghcr.io/nkg/warp-wireproxy
Compose

See docker-compose.yml for a hardened example (cap_drop: ALL, read_only, no-new-privileges).

docker compose up -d
docker compose exec warp /usr/bin/warp-reg health && echo healthy
Many instances

Each instance registers its own WARP device, with its own WireGuard keys and tunnel address. The shipped compose file scales as-is:

docker compose up -d --scale warp=10
docker compose ps                      # shows the host port for each replica

Ports come from a host-side range, so replicas never collide:

ports:
  - "${SOCKS5_PORTS:-1080-1089}:1080"
  - "${HTTP_PORTS:-8080-8089}:8080"

Widen the range (via a .env file or by editing) to scale past ten. The container listens on 1080/8080 internally and each replica has its own network namespace, so the internal ports never collide — only the host side has to vary. This stops being true if you attach instances to a shared network namespace; see Shared network namespaces.

Two things will quietly collapse your instances into one identity:

  • A named volume. warp-state:/var/lib/warp-wireproxy is shared by every replica, so they all load the same WireGuard keys, present to Cloudflare as a single device, and race to write the same registration.json. The compose file uses an anonymous volume (- /var/lib/warp-wireproxy) so each replica gets its own, persisted across restarts of that replica. Use named volumes only if you declare one per instance.
  • container_name. Container names must be unique, so setting one caps you at a single replica. It is deliberately unset.

If you need a specific host port pinned to a specific instance rather than whatever the range hands out, declare the services explicitly — there is a worked example in the comments at the bottom of docker-compose.yml.

Registering many devices at once is fine: warp-reg retries with exponential backoff if Cloudflare rate-limits, rather than crash-looping.

On egress IPs. Instances usually leave from different Cloudflare addresses, but that is a property of Cloudflare's anycast routing, not of the tunnel identity: the egress address is chosen per session, so it is not guaranteed unique per instance and can change on reconnect. Measured on a two-instance run, separate registrations gave both different tunnel addresses and different egress IPs — but two instances sharing one registration also showed different egress IPs while using a single WireGuard identity. Do not use "the IPs differ" as evidence that your instances are configured correctly; check the tunnel address instead:

docker compose exec --index 1 warp /bin/sh -c 'grep ^Address /var/lib/warp-wireproxy/wireproxy.conf'
docker compose exec --index 2 warp /bin/sh -c 'grep ^Address /var/lib/warp-wireproxy/wireproxy.conf'

Different Address lines mean genuinely separate devices. Identical ones mean the instances are sharing state — see the volume note above.

EXPOSE 1080 8080 in the Dockerfile is metadata only. It does not bind, publish or reserve anything, so it never constrains the port variables. It feeds only docker run -P.

Shared network namespaces

By default each container gets its own network namespace, so replicas can all listen on the same internal ports. If you instead attach containers to an existing namespace — network_mode: "service:<name>" or network_mode: "container:<id>", the pattern used for sidecars — they share one port space, and the rules change:

Every listener needs its own port. Not just the proxies: SOCKS5_PORT, HTTP_PORT, INFO_BIND and METRICS_BIND must all differ between instances in the same namespace.

INFO_BIND is the one that catches people. It defaults to 127.0.0.1:9080 for every instance, so a second instance collides even when you have correctly given it a different SOCKS5 port.

Before v1.0.1 the image also shipped SOCKS5_BIND, HTTP_BIND and INFO_BIND as ENV defaults. Because _BIND wins over _PORT, and nothing can distinguish an image default from a value you set, SOCKS5_PORT and HTTP_PORT were read and then discarded — so every instance in a shared namespace bound 1080, one won, and the rest crash-looped on "address already in use". On those versions, set SOCKS5_BIND=0.0.0.0:1081 rather than SOCKS5_PORT=1081. warp-reg probes all of its listeners before registering and fails with a message naming the variable, rather than letting wireproxy panic with a stack trace after a WARP device has already been enrolled:

cannot bind 127.0.0.1:9080 (INFO_BIND): bind: address already in use

Set INFO_BIND="" to switch the health endpoint off entirely if you would rather not allocate a port per instance — at the cost of the container healthcheck and the Prometheus exporter.

Ports are published on the owning service. A container joining another's namespace may not declare ports: of its own; Compose rejects it. Publish each instance's port on whichever service owns the namespace.

sysctls cannot be set at all on a container sharing a namespace — Docker rejects the combination. This image needs none, so that is only a concern when migrating a config from an image that did.

A worked layout for two instances in one namespace:

InstanceSOCKS5_PORTINFO_BIND
11080127.0.0.1:9080
21081127.0.0.1:9081
Apple container (macOS 26+)

The image runs unmodified on Apple's container, and that is what it is developed and tested against:

container run -d --name warp \
    -p 1080:1080 -p 8080:8080 \
    -v warp-state:/var/lib/warp-wireproxy \
    ghcr.io/nkg/warp-wireproxy

curl -x socks5h://127.0.0.1:1080 https://www.cloudflare.com/cdn-cgi/trace

Four differences from Docker are worth knowing:

No healthcheck support. container ignores the image's HEALTHCHECK, so there is no State.Health to inspect. Run the same probe by hand — it is the identical command the HEALTHCHECK invokes:

container exec warp /usr/bin/warp-reg health && echo healthy

No compose. Use container run with the flags above; the docker-compose.yml hardening options (cap_drop, read_only) have no equivalent and are simply not needed — the container is already non-root with no capabilities, and each one gets its own VM.

Each container gets a routable IP, so you can skip -p entirely and talk to it directly:

container ls           # note the ADDR column, e.g. 192.168.64.18
curl -x socks5h://192.168.64.18:1080 https://www.cloudflare.com/cdn-cgi/trace

The builder runs in its own VM and sometimes comes up without a working resolver, which shows as apk ... temporary error (try again later) during the build. Recreate it:

container builder delete
container builder start --cpus 4 --memory 8G --dns 192.168.64.1

Note --memory needs a unit suffix — a bare 8192 is parsed as zero and rejected.

Verify
curl -x socks5h://127.0.0.1:1080 https://www.cloudflare.com/cdn-cgi/trace   # SOCKS5
curl -x http://127.0.0.1:8080    https://www.cloudflare.com/cdn-cgi/trace   # HTTP

warp=on means traffic is exiting through Cloudflare. Use socks5h:// rather than socks5:// so DNS is resolved through the tunnel too, not on your host.

Configuration

Everything is environment variables. Every one has a working default; running the image with no configuration at all gives you a working dual-stack proxy.

Listeners
VariableDescriptionDefault
SOCKS5_BINDSOCKS5 listen address. Empty string disables it.0.0.0.0:1080
SOCKS5_PORTPort only; ignored if SOCKS5_BIND is set.
SOCKS5_HOSTHost for SOCKS5_PORT.0.0.0.0
SOCKS5_USER / SOCKS5_PASSWDSOCKS5 auth. Must be set together. No spaces.
HTTP_BINDHTTP proxy listen address. Empty string disables it.0.0.0.0:8080
HTTP_PORT / HTTP_HOSTAs above.
HTTP_USER / HTTP_PASSWDHTTP proxy auth. Must be set together. No spaces.

Setting only one half of a credential pair is rejected at startup rather than silently leaving the proxy open.

Tunnel
VariableDescriptionDefault
WARP_MTUTunnel MTU.1280
DNS_SERVERSResolvers used inside the tunnel, comma-separated.Cloudflare v4 + v6
WARP_ENDPOINTWireGuard peer endpoint.engage.cloudflareclient.com:2408
WARP_KEEPALIVEPersistentKeepalive, seconds.25
WARP_APIRegistration API.…cloudflareclient.com/v0a2025/reg
WARP_STATE_DIRWhere the registration is cached. Empty disables caching./var/lib/warp-wireproxy
WARP_REREGISTERForce a new device on every start.false
WARP_REGISTER_RETRIESRegistration attempts before giving up.5
WARP_REGISTER_TIMEOUTPer-attempt HTTP timeout, seconds.30
Health, metrics and config
VariableDescriptionDefault
INFO_BINDwireproxy's /metrics + /readyz endpoint. Empty disables it.127.0.0.1:9080
METRICS_BINDPrometheus exporter address. Unset = exporter off.
CHECK_ALIVEAddresses pinged through the tunnel to drive /readyz.1.1.1.1, 2606:4700:4700::1111
CHECK_ALIVE_INTERVALPing interval, seconds.5
WIREPROXY_CONFConfig file path. A file you supply here is used as-is; one warp-reg generated is regenerated./etc/wireproxy/wireproxy.conf
WG_CONFIGUse an existing WireGuard config instead of registering.
EXTRA_CONFIGRaw text appended to the generated config.
WIREPROXY_SILENTPass -s to wireproxy.false
Persistence

Mount a volume at /var/lib/warp-wireproxy and the WARP registration is cached there. Restarts then reuse the same device and keys instead of enrolling a new one with Cloudflare each time, which avoids both API rate-limiting and a trail of orphaned device registrations.

Persistence is best-effort: if the directory is unwritable (a read-only mount, a volume owned by another uid) the container logs a warning and registers afresh rather than refusing to start.

Health and metrics

Healthcheck

The image's HEALTHCHECK runs warp-reg health, which queries wireproxy's /readyz. That endpoint is backed by ICMP echoes that actually traverse the tunnel to Cloudflare and back — so it reports on the tunnel, not merely on whether a socket is listening.

docker inspect -f '{{.State.Health.Status}}' warp
Prometheus

Set METRICS_BIND and the entrypoint also serves a Prometheus exporter. It is built into the same binary, so it adds nothing to the image.

docker run -d -p 1080:1080 -p 9095:9095 \
    -e METRICS_BIND=0.0.0.0:9095 \
    ghcr.io/nkg/warp-wireproxy

curl -s http://127.0.0.1:9095/metrics

wireproxy's own /metrics speaks the WireGuard UAPI dialect (tx_bytes=…, hex keys), which Prometheus cannot scrape. The exporter translates it:

MetricTypeMeaning
warp_wireproxy_upgaugewireproxy's info endpoint responded
warp_wireproxy_readygaugeevery CHECK_ALIVE target is answering
warp_wireproxy_peersgaugeconfigured WireGuard peers
warp_wireproxy_peer_receive_bytes_totalcounterbytes received from the peer
warp_wireproxy_peer_transmit_bytes_totalcounterbytes sent to the peer
warp_wireproxy_peer_last_handshake_timestamp_secondsgaugelast completed handshake
warp_wireproxy_peer_handshake_age_secondsgaugeseconds since handshake, -1 if never
warp_wireproxy_peer_persistent_keepalive_secondsgaugeconfigured keepalive
warp_wireproxy_check_alive_last_pong_timestamp_secondsgaugelast pong per target
warp_wireproxy_device_listen_portgaugelocal UDP source port
warp_wireproxy_device_errnogaugedevice errno, 0 is healthy
warp_wireproxy_scrape_duration_secondsgaugetime spent scraping
warp_wireproxy_build_infogaugeversion label

Peer keys are re-encoded from hex to the base64 form wg show prints, so they match your config by eye.

An alert worth having:

- alert: WarpTunnelStale
  expr: warp_wireproxy_ready == 0 or warp_wireproxy_peer_handshake_age_seconds > 300
  for: 5m

/healthz on the same port is a plain 200/503 liveness probe for orchestrators that want one separate from the scrape.

Debugging

warp-reg is also a small CLI:

docker exec warp /usr/bin/warp-reg config    # show the generated config
docker exec warp /usr/bin/warp-reg metrics   # one Prometheus scrape
docker exec warp /usr/bin/warp-reg health    # exit 0 if the tunnel is up
docker exec warp /usr/bin/warp-reg version
docker exec -it warp /bin/sh                 # busybox, unless WITH_SHELL=false

Bring your own config

The image is not WARP-only.

A complete wireproxy config — a file you put at WIREPROXY_CONF is used verbatim and registration is skipped:

docker run -d -p 1080:1080 \
    -v ./my.conf:/etc/wireproxy/wireproxy.conf:ro \
    ghcr.io/nkg/warp-wireproxy

An existing WireGuard configWG_CONFIG points at a wg-quick file; the proxy sections still come from the environment:

docker run -d -p 1080:1080 \
    -e WG_CONFIG=/wg/peer.conf \
    -v ./peer.conf:/wg/peer.conf:ro \
    ghcr.io/nkg/warp-wireproxy

Extra sectionsEXTRA_CONFIG is appended verbatim, which is how you reach wireproxy features that have no environment variable of their own:

docker run -d -p 25565:25565 \
    -e EXTRA_CONFIG=$'[TCPClientTunnel]\nBindAddress = 0.0.0.0:25565\nTarget = play.example.net:25565' \
    ghcr.io/nkg/warp-wireproxy

warp-reg tells the two apart by the # Generated by warp-reg header it writes. A config it generated is regenerated on every start, so changes to environment variables actually take effect; a config without that header is never touched. Without this distinction the file written on first boot would be mistaken for yours on every later boot, and no environment change would ever apply.

See wireproxy's README for [TCPClientTunnel], [TCPServerTunnel], [UDPProxyTunnel], [SNI], TunnelDomains and friends.

Architectures

Published by default:

linux/amd64 · linux/386 · linux/arm/v6 · linux/arm/v7 · linux/arm64 · linux/ppc64le · linux/riscv64 · linux/s390x

That is a superset of every linux binary wireproxy itself releases — upstream ships no ppc64le, and its single arm asset is GOARM=6, so arm/v7 hosts would otherwise run the v6 build. Building from source fixes both.

linux/loong64 and linux/mips64le also compile, but no alpine image is published for them, so the bundled busybox cannot be built. Build with --build-arg WITH_SHELL=false to target them (and to save ~1 MB, at the cost of docker exec … /bin/sh).

How it works

                       ┌─────────────────────────────────────────┐
  your app             │ container (scratch, non-root, no caps)  │
      │                │                                         │
      │ SOCKS5 :1080   │   ┌──────────┐      ┌────────────────┐  │
      ├───────────────►│──►│          │      │                │  │
      │ HTTP   :8080   │   │ wireproxy├─────►│ userspace      │  │
      ├───────────────►│──►│          │ UDP  │ WireGuard      │──┼──► Cloudflare
      │                │   └────┬─────┘      └────────────────┘  │    WARP
      │ Prom   :9095   │        │ /metrics /readyz               │
      └───────────────►│──► warp-reg (exporter + healthcheck)    │
                       └─────────────────────────────────────────┘

On start, warp-reg:

  1. Reuses the cached registration, or generates a clamped X25519 keypair and POSTs the public key to Cloudflare's registration API, retrying with exponential backoff.
  2. Renders wireproxy.conf from the environment.
  3. Hands off to wireproxy. Without METRICS_BIND it execs, so wireproxy becomes PID 1 and receives signals directly with no supervisor in the way; with it, warp-reg stays as a parent to serve the exporter and forwards SIGTERM.
One thing worth knowing

wireproxy loads its ini file with AllowShadows, but reads multi-valued keys via key.String() — which returns only the first occurrence — and then splits that on commas itself. So this:

Address = 172.16.0.2/32
Address = 2606:4700:110::1/128     # silently ignored
AllowedIPs = 0.0.0.0/0
AllowedIPs = ::/0                  # silently ignored

quietly gives you an IPv4-only tunnel with no IPv6 route, while looking correct. The generated config always writes them on one line:

Address = 172.16.0.2/32, 2606:4700:110::1/128
AllowedIPs = 0.0.0.0/0, ::/0

There is a regression test pinning this.

Building

git clone https://github.com/nkg/docker-warp-wireproxy
cd docker-warp-wireproxy

go test ./...          # unit tests, no container runtime needed
scripts/test.sh        # build the image and verify it reaches WARP

scripts/test.sh auto-detects Docker, Apple container, or Podman; override with RUNTIME=container. It runs 18 checks including a live warp=on probe, egress-IP masking, the Prometheus endpoint, and restart behaviour.

Building by hand:

docker build -t warp-wireproxy .
container build --platform linux/arm64 -t warp-wireproxy .   # Apple container

Build arguments:

ArgDescriptionDefault
WIREPROXY_REFwireproxy git tag to build.see .wireproxy-version
WIREPROXY_REPOwireproxy fork to build from.whyvl/wireproxy
WITH_SHELLBundle busybox at /bin/sh.true
VERSIONStamped into warp-reg version.dev

Multi-arch:

docker buildx build --platform linux/amd64,linux/arm64 -t warp-wireproxy .

Both Go stages cross-compile from $BUILDPLATFORM, so multi-arch builds do not run under QEMU.

Where the size goes

Measured inside a running arm64 container with du:

ComponentSize
wireproxy9.2 MB
warp-reg5.9 MB
busybox (static, optional)1.1 MB
CA bundle, passwd, nsswitch0.2 MB
Total16.5 MB

amd64 binaries are slightly larger, so expect ~17.5 MB there.

Both binaries are Go, and most of warp-reg is net/http plus crypto/tls — the cost of doing the WARP registration and the Prometheus exporter without shipping curl, jq and openssl. Dropping the exporter would save roughly 2 MB; dropping busybox saves another 1 MB and adds two architectures.

Automation

Two workflows, in .github/workflows.

build.yml

Runs on push, PR, manual dispatch, and weekly. Five jobs:

JobWhat it does
resolveDecides which wireproxy version this run builds
testgofmt, go vet, go test -race, and a version-pin drift check
cross-compileBuilds both binaries for all 10 architectures in parallel
verifyBuilds an amd64 image, runs it, and requires a real warp=on
publishMulti-arch build and push to GHCR (and Docker Hub, if configured)

verify gates publish, so nothing reaches the registry until a container has actually been started and proven to route traffic through WARP. Pull requests never publish.

The weekly cron rebuilds the current pin, which is how the image picks up base image and CA bundle updates without anyone pushing a commit.

watch-upstream.yml

Polls whyvl/wireproxy every 6 hours and publishes a new image when a release appears.

check ──► new release? ──no──► done
              │
             yes
              ▼
          build.yml (test → cross-compile → verify → publish)
              │
        ┌─────┴─────┐
     success      failure
        │             │
        ▼             ▼
   commit the    open/update a
   version bump  tracking issue

Three details worth knowing:

It publishes before it commits. The version bump lands in the repo only after the new upstream release has been built, verified against live WARP, and pushed. A broken upstream release therefore leaves the pin untouched, files an issue, and gets retried on the next tick — rather than pinning the repo to something that does not build.

It calls the build workflow directly rather than relying on its own commit to trigger it. Pushes made with GITHUB_TOKEN deliberately do not trigger further workflows, so a commit-and-hope design would silently never build. The version is passed as a workflow_call input rather than read from the checkout, because at that point the bump is not committed yet.

.wireproxy-version is the single source of truth. The test job fails if the Dockerfile's ARG WIREPROXY_REF defaults drift away from it.

Manual control:

gh workflow run watch-upstream.yml                    # check now
gh workflow run watch-upstream.yml -f ref=v1.1.2      # build a specific tag
gh workflow run watch-upstream.yml -f force=true      # rebuild the current pin

Images are tagged latest, wireproxy-<upstream tag>, and sha-<short sha>, so you can pi

Tag summary

Content type

Image

Digest

sha256:24464fb8f

Size

7.5 MB

Last updated

11 days ago

docker pull earcandy/warp-wireproxy