A Helm chart (and the container image it runs) for deploying a single Tor v3 onion service in front of an existing Kubernetes Service.
This project has exactly one job: take an in-cluster backend (e.g. a web app you already deploy) and expose it as a Tor v3 hidden service, with sensible security defaults and as little surface area as possible.
What this is. A Helm chart designed to be consumed as a dependency of an application chart, plus a minimal Debian-based Tor image built from the official Tor Project apt repository. One Deployment, one Secret, one ConfigMap (and an optional hardened nginx in front).
What this is not.
- A general Tor operator. If you want CRDs, multiple onion services per
cluster managed declaratively, automatic key rotation, etc., look at
tor-controller. It does far more than this chart does, with corresponding complexity. - A Tor relay, bridge, or exit node; different deployment shapes, different security posture, out of scope.
- A SOCKS proxy. The chart deliberately disables
SocksPortin torrc. - An identity manager. You generate the onion keys; the chart consumes
them. The
.onionaddress is yours and stable across reinstalls.
The whole point is to be boring and small so it can drop into a
parent chart's dependencies: block without forcing operational
complexity on the consumer and to be as explicit as possible. Very nice for gitops.
| Artifact | Path | Published as |
|---|---|---|
| Container image | docker/Dockerfile |
${OCI_REGISTRY}/library/tor:<appVersion> |
| Helm chart | helm/ |
${OCI_REGISTRY}/library/charts/tor:<chart-version> |
Both are pushed by GitHub Actions on push to main / development
(.github/workflows/image.yml,
.github/workflows/helm.yml).
Tor network
│
▼
┌──────────────────┐ ┌─────────────────┐
│ tor (1 pod) │ │ nginx (opt.) │
│ HiddenService v3│ ──────▶ │ proxy + harden │ ──▶ backend.svc:port
│ emptyDir /var/ │ │ (rate limit, │
│ lib/tor │ │ no gzip, │
│ keys: Secret │ │ no headers) │
└──────────────────┘ └─────────────────┘
When nginx.enabled=false (default), tor connects directly to
backend.host:backend.port. When nginx.enabled=true, tor connects to
the in-cluster nginx Service, which proxies to the backend with a
hardened configuration suited to onion traffic (compression off,
identifying request headers stripped, rate-limited).
In your parent chart's Chart.yaml:
dependencies:
- name: tor
version: 0.1.0
repository: oci://harbor.peinser.com/library/charts
condition: tor.enabledIn your parent chart's values.yaml:
tor:
enabled: true
backend:
host: my-app.my-namespace.svc.cluster.local
port: 8080
hiddenService:
existingSecret: my-onion-keys
# Optional reverse proxy; recommended when the backend doesn't sanitize
# request headers itself.
nginx:
enabled: trueResources are named <release>-tor and <release>-nginx the parent
release name is the prefix.
You need three files: hs_ed25519_secret_key, hs_ed25519_public_key,
and hostname. Two common ways to produce them:
mkdir -p ./onion-keys
docker run --rm --user 100:101 \
--entrypoint sh \
-v "$PWD/onion-keys:/data" \
harbor.peinser.com/library/tor:<appVersion> \
-c '
install -d -m 0700 /data/hidden_service
tor --quiet \
--DataDirectory /data \
--HiddenServiceDir /data/hidden_service \
--HiddenServiceVersion 3 \
--HiddenServicePort "80 127.0.0.1:80" \
--SocksPort 0 --ORPort 0 --DirPort 0 \
--DisableNetwork 1 &
pid=$!
while [ ! -f /data/hidden_service/hostname ]; do sleep 0.2; done
kill $pid
cat /data/hidden_service/hostname
'mkp224o brute-forces ed25519
keypairs whose derived .onion matches a chosen prefix.
# brew install mkp224o (or build from source)
mkp224o -d ./onion-keys -n 1 test
# mkp224o writes ./onion-keys/test...xyz.onion/ rename to hidden_service
mv ./onion-keys/test*.onion ./onion-keys/hidden_serviceEither path produces:
onion-keys/hidden_service/
├── hs_ed25519_secret_key # binary, never share
├── hs_ed25519_public_key # binary
└── hostname # text, your .onion address
kubectl --namespace my-namespace create secret generic my-onion-keys \
--from-file=hs_ed25519_secret_key=onion-keys/hidden_service/hs_ed25519_secret_key \
--from-file=hs_ed25519_public_key=onion-keys/hidden_service/hs_ed25519_public_key \
--from-file=hostname=onion-keys/hidden_service/hostnametor:
hiddenService:
existingSecret: my-onion-keysThis integrates with Sealed Secrets or External Secrets produce the Secret through whichever flow you already use, then point the chart at its name.
Only useful when your values file is itself encrypted at rest (sops, etc.).
tor:
hiddenService:
keys:
hsSecretKey: "<base64 of hs_ed25519_secret_key>"
hsPublicKey: "<base64 of hs_ed25519_public_key>"
hostname: "<your-address>.onion"The chart renders a chart-managed Secret from these values. The keys then live in the helm release storage as well as in the rendered Secret.
Common knobs only. See helm/values.yaml for the
full set with defaults and inline documentation.
| Key | Default | Notes |
|---|---|---|
backend.host |
"" |
Required when nginx.enabled=false. Cluster service the onion routes to. |
backend.port |
80 |
|
onionService.port |
80 |
Public port advertised on the .onion. |
hiddenService.existingSecret |
"" |
Name of pre-existing Secret holding the three key files. |
hiddenService.keys.* |
"" |
Inline alternative; see above. |
image.repository |
harbor.peinser.com/library/tor |
Override for your own registry. |
image.tag |
"" |
Falls back to Chart.AppVersion. |
nginx.enabled |
false |
Insert hardened nginx between tor and the backend. |
nginx.rateLimit.rate |
60r/m |
Per-source rate limit. |
nginx.rateLimit.burst |
120 |
|
networkPolicy.enabled |
true |
Tor: deny ingress. Nginx: ingress only from tor; egress only to backend + DNS. |
- Pod runs as
debian-tor(UID 100 / GID 101),runAsNonRoot,readOnlyRootFilesystem, all capabilities dropped, seccompRuntimeDefault, no service-account token mounted. - torrc disables every listener except the hidden service:
SocksPort/ControlPort/ORPort/DirPort/ExitRelay = 0.SafeLogging 1. Intro-point DoS defenses on. - Onion keys are mounted from a Secret (read-only,
defaultMode 0400), copied by an init container into emptyDir at/var/lib/tor/hidden_servicewith mode0700/0600tor refuses the Secret tmpfs mount mode directly. - Deployment
strategy: Recreateso two tor pods never publish the same HS descriptor concurrently during a rollout. - NetworkPolicy on by default: tor accepts no in-cluster ingress (it only initiates outbound). When nginx is enabled, nginx accepts ingress only from the tor pod and may egress only to the backend port + DNS.
- nginx reverse proxy (when enabled):
server_tokens off, gzip off (BREACH-style risk), client-suppliedX-Forwarded-*headers stripped, per-source rate limit, all temp paths under/tmp(read-only root FS).
The Dockerfile installs tor from the official Tor Project Debian
repository (deb.torproject.org), which tracks upstream stable promptly
and is GPG-signed. Two stages:
validateCI smoke test (tor --version,tor --verify-configagainst a minimal known-valid config).productionslim final image, runs asdebian-tor(UID 100).
docker buildx build --target production -f docker/Dockerfile -t tor:dev docker/The debian-tor user is pre-created with stable UID 100 / GID 101 so
the chart's pod security context is portable across image rebuilds.
| Workflow | Trigger | What it does |
|---|---|---|
image.yml |
docker/** or helm/Chart.yaml change on main/development |
Builds & pushes ${OCI_REGISTRY}/library/tor:<appVersion>-<sha>. Image version comes from helm/Chart.yaml's appVersion. |
helm.yml |
helm/** change on main/development |
helm lint → helm package → helm push to ${OCI_REGISTRY}/library/charts. On main only, also retags as 0.0.0-latest via crane. |
Both expect these GitHub Actions repo settings:
- Variable
OCI_REGISTRYregistry host (e.g.harbor.peinser.com). - Secrets
OCI_REGISTRY_HARBOR_LOGINandOCI_REGISTRY_HARBOR_SECRET. - Self-hosted runner group
on-prem.
BSD-3-Clause (matches upstream Tor). See LICENSE.