Routing Elixir HTTP traffic through Tor with Privoxy and Finch
RSS readers are quiet little HTTP clients. They poll dozens — sometimes hundreds — of URLs on a schedule, often from the same IP address, often with the same user agent. Most sites don’t care. Some do.
I run SlashFeed on my own machine, which means all outbound fetch traffic comes from a residential IP. That’s actually fine most of the time, but a subset of sites — particularly those with aggressive bot detection or paywalls — started returning 403s or throttling responses. Rotating user agents helped a little. Adding random jitter between requests helped a little more. But it felt like playing whack-a-mole.
I didn’t want to pay for a proxy service or route all traffic through a VPN. This is a personal tool. I wanted something self-hosted, free, and composable with the Elixir stack I already had.
The answer was Tor — specifically, using Privoxy as an HTTP-to-SOCKS5 bridge in front of the Tor SOCKS proxy, and wiring it into a dedicated Finch pool.
The plumbing: Tor + Privoxy
Tor exposes a SOCKS5 proxy on localhost:9050 by default. The problem is that Finch (and most HTTP clients) speak HTTP proxies, not SOCKS5. Privoxy solves this: it listens on an HTTP proxy port and forwards traffic to the Tor SOCKS5 proxy.
With both installed (brew install tor privoxy on macOS, or the equivalent packages on Linux), the Privoxy config is minimal. In /usr/local/etc/privoxy/config (or wherever your distro puts it), you add:
forward-socks5 / 127.0.0.1:9050 .
listen-address 127.0.0.1:8118
This tells Privoxy to listen on port 8118 and forward all traffic to the Tor SOCKS5 proxy on port 9050. Start both services:
brew services start tor
brew services start privoxy
You can verify it’s working with curl:
curl --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip
# {"IsTor":true,"IP":"..."}
The Elixir side: a dedicated Finch pool
Finch makes this clean. Rather than configuring a global proxy (which would route all HTTP traffic through Tor — not what I want), I define a second named Finch instance specifically for Tor-routed requests.
In application.ex:
children = [
# Default pool — no proxy
{Finch, name: SlashFeed.Finch},
# Tor pool — all requests go through Privoxy → Tor
{Finch,
name: SlashFeed.Finch.Tor,
pools: %{
:default => [
conn_opts: [
proxy: {:http, "127.0.0.1", 8118, []}
]
]
}}
]
The proxy option in conn_opts is passed straight through to Mint, Finch’s underlying HTTP client. Mint supports HTTP CONNECT proxies, which is exactly what Privoxy exposes.
When making a request, you choose which pool to use by passing the finch option:
# Normal fetch
Req.get(url, finch: SlashFeed.Finch)
# Tor-routed fetch
Req.get(url, finch: SlashFeed.Finch.Tor)
I use Req as the HTTP client layer on top of Finch, so swapping pools is a single keyword argument at the call site.
Routing logic
Not every feed needs to go through Tor — that would be slow and wasteful. I added a boolean flag use_tor on the Feed schema, so the routing decision is per-feed and stored in the database. The fetch worker checks this flag and picks the appropriate Finch pool:
defp finch_pool(%Feed{use_tor: true}), do: SlashFeed.Finch.Tor
defp finch_pool(%Feed{}), do: SlashFeed.Finch
In practice, I enable Tor for a small number of feeds where I’ve hit persistent 403s. Everything else continues through the default pool.
What surprised me
Latency is real but acceptable. Tor adds noticeable latency — typically 2–5 seconds per request rather than a few hundred milliseconds. For RSS polling on a background schedule, this is completely fine. I use Oban for job scheduling, so fetches run async and the latency is invisible to the user.
Exit node IP rotation is automatic. Every Tor circuit uses a different exit node, so the source IP changes between requests. This isn’t something I had to configure; it’s just how Tor works.
HTTPS works correctly end-to-end. Privoxy forwards the CONNECT tunnel to Tor, and TLS is established between the Elixir process and the destination server. Privoxy never sees the plaintext content of HTTPS requests.
Some .onion sites are reachable. This wasn’t a goal, but it works — if a feed ever lives on an onion service, the same pool handles it.
Is this overkill for an RSS reader?
Probably. But SlashFeed is a personal project, which means I get to make this kind of call without a product manager asking about the P99 latency impact. The Tor pool sits quietly in application.ex, adds zero overhead to the feeds that don’t use it, and solves a real problem for the few that do.
The broader pattern — multiple named Finch pools with different transport configurations, selected at the call site — is genuinely useful beyond this specific use case. You could use the same approach to route traffic through different outbound interfaces, apply different TLS configurations per domain, or isolate third-party API traffic from first-party traffic for observability purposes.
SlashFeed is a personal RSS reader I’m building in Elixir/Phoenix. I’m documenting the interesting problems here as I go.