Outbound Traffic Control for Docker Services

Every developer who’s worried about their services reaching out unnecessarily faces the same problem: blocking internet access on Docker containers is easy — just use internal bridge networks. But then your service breaks because it needs to talk to an API for updates, or a CDN for assets, or GitHub to clone a repository.

In networking terms, egress means traffic leaving your network. By default, Docker containers can make outbound connections to anywhere. What we’re building here is egress filtering: blocking all outbound traffic by default, then whitelisting only what each service actually needs.

Here’s how it works.

The Network Pattern

The foundation is simple: every isolated compose file uses the same three networks — default (internal, no internet), net (shared with Traefik for inbound traffic), and internet (egress). Services stay on the internal network, and only the squid container bridges to internet.

# docker-compose.yml
services:
  my-service:
    image: some/service
    networks:
      - default # internal — no internet
      - net # external network shared with traefik

  squid:
    image: ubuntu/squid:latest
    volumes:
      - ./squid.conf:/etc/squid/squid.conf
    expose:
      - 3128
    networks:
      - default # connected to internal network
      - internet # also bridges to external network

networks:
  default:
    internal: true # <-- this is the magic line
  net:
    external: true # pre-existing network, shared with traefik for inbound traffic
  internet:
    external: true # pre-existing network with internet access

The internal: true flag on default means Docker blocks all outbound traffic at the network driver level. Containers on this network can talk to each other, but nothing leaves the Docker host. Only containers attached to the internet network can reach the outside world.

So here’s the pattern: services that need internet don’t attach to internet themselves. They send their HTTP requests to a squid container that sits on both networks and enforces what can go out.

Squid as the Only Egress Point

The squid configuration is where all the actual enforcement happens. Here’s the basic pattern I use for every service:

# services/gallery/squid.conf
acl allowed_domains dstdomain auth.okuto.id
acl docker_network src 172.16.0.0/12
http_port 3128
http_access allow docker_network allowed_domains
http_access deny all

This allows any container in the 172.16.0.0/12 range (where Docker places internal networks) to make HTTP requests — but only to auth.okuto.id. Everything else is silently dropped by deny all.

For services that need more domains, you add more acl allowed_domains dstdomain lines:

# services/code/squid.conf
acl allowed_domains dstdomain auth.okuto.id
acl allowed_domains dstdomain .github.com  # dot prefix = wildcard subdomains
acl docker_network src 172.16.0.0/12
http_port 3128
http_access allow docker_network allowed_domains
http_access deny all

The dot prefix on .github.com is important — it matches api.github.com and all other subdomains of github.com, but not lookalike names such as gist.githubusercontent.com, which would need their own line. A bare domain name like example.com only matches that exact domain, not its subdomains.

Services get configured to use the proxy by setting a single environment variable:

environment:
  HTTPS_PROXY: http://squid:3128

Docker’s built-in DNS resolves squid automatically — no IP addresses needed. Note that only HTTPS_PROXY is set, not HTTP_PROXY: nearly all outbound API traffic is HTTPS, so it gets routed through squid, while plain-HTTP calls to internal services (databases, other containers) bypass the proxy entirely. That way, adding a new internal service never requires touching the squid configuration.

The Full Picture: Traefik as the Hub

Traefik connects all these networks together. Here’s a look at its network setup from my traefik compose file:

# services/traefik/compose.yml
networks:
  internet:
  net:

Traefik sits on every external network, which means it can receive traffic from anywhere and route to anything. It is the single inbound hub — everything coming into the system arrives at traefik, while everything leaving goes through squid. Every other service stays behind the internal network.

The entrypoints configuration shows how different kinds of traffic flow:

# services/traefik/compose.yml
command:
  # HTTP → HTTPS redirect
  - '--entrypoints.web.address=:80/tcp'
  - '--entrypoints.web.http.redirections.entrypoint.to=websecure'

  # HTTPS with Let's Encrypt
  - '--entrypoints.websecure.address=:443/tcp'
  - '--entrypoints.websecure.http.tls.certResolver=letsencrypt'

  # SSH for Git — TCP, no TLS
  - '--entrypoints.code-git.address=:2211/tcp'

Real Examples: What Each Service Gets

AI (Ollama + Open WebUI) — 2 domains

Open WebUI connects to my authentication provider for OAuth logins. That’s the only external call it makes. Ollama needs one more, for model downloads.

# services/ai/squid.conf
acl allowed_domains dstdomain auth.okuto.id
acl allowed_domains dstdomain registry.ollama.ai
acl docker_network src 172.16.0.0/12
http_port 3128
http_access allow docker_network allowed_domains
http_access deny all

Both ollama and open-webui containers get the proxy configured. ollama pull downloads models from registry.ollama.ai, which is why it appears in the whitelist.

Code (Forgejo) — 4 domains

The Forgejo git service needs auth.okuto.id for authentication and .github.com for mirroring repositories through the GitHub API. The .docker.io and .ghcr.io entries cover the runner service’s image pulls.

# services/code/squid.conf
acl allowed_domains dstdomain auth.okuto.id
acl allowed_domains dstdomain .github.com
acl allowed_domains dstdomain .docker.io
acl allowed_domains dstdomain .ghcr.io
acl docker_network src 172.16.0.0/12
http_port 3128
http_access allow docker_network allowed_domains
http_access deny all

The runner service also needs to pull images from external registries, which is why .docker.io and .ghcr.io are whitelisted here. It shares this squid setup through the compose file’s shared network topology.

Immich’s image management and transcoding are entirely local operations. The single allowed domain is for authentication tokens.

# services/gallery/squid.conf
acl allowed_domains dstdomain auth.okuto.id
acl docker_network src 172.16.0.0/12
http_port 3128
http_access allow docker_network allowed_domains
http_access deny all

Search (SearXNG) — 20+ domains

This is where the whitelist gets interesting. SearXNG is a metasearch engine that fetches results from dozens of providers simultaneously. Its squid.conf spans search engines, image sources, and video providers:

# services/search/squid.conf
acl allowed_domains dstdomain .duckduckgo.com
acl allowed_domains dstdomain .brave.com
acl allowed_domains dstdomain .google.com
acl allowed_domains dstdomain en.wikipedia.org
acl allowed_domains dstdomain query.wikidata.org
acl allowed_domains dstdomain cdn.jsdelivr.net
acl allowed_domains dstdomain www.flickr.com
acl allowed_domains dstdomain www.pexels.com
acl allowed_domains dstdomain www.pinterest.com
# etc...

acl docker_network src 172.16.0.0/12
http_port 3128
http_access allow docker_network allowed_domains
http_access deny all

Search required the most careful maintenance because every time I add a new search provider in SearXNG’s config, I need to check if it’s covered by an existing wildcard or add a new line.

The Git Port Problem

Here’s where things get tricky with git servers. Forgejo serves SSH for git operations over a specific port — mine uses 2211. Remote URLs look like this:

ssh://git@code.okuto.id:2211/dimas/web.git

The natural fix would be to expose port 2211 on the Forgejo container with ports:. But every other externally-facing service in this setup is reached through traefik — publishing host ports directly would bypass the hub and break the pattern.

The solution is routing the port through Traefik’s TCP router:

Expose it on the host in traefik:

# services/traefik/compose.yml
ports:
  # code git
  - '2211:2211'

Route it to Forgejo via Traefik labels:

# services/code/compose.yml
labels:
  traefik.enable: 'true'
  traefik.tcp.routers.code-git.entrypoints: code-git
  traefik.tcp.routers.code-git.rule: HostSNI(`*`)
  traefik.tcp.services.code-git.loadBalancer.server.port: 2211

The HostSNI(*) rule matches any SNI value, which is standard for SSH connections since they don’t use TLS handshakes. Traefik accepts the incoming connection on port 2211 and forwards it to Forgejo’s port 2211 — entirely within Docker, no internet access needed by the git service itself.

What Happens When Things Break

When a service starts failing with connection errors, follow these steps:

  1. Check which service is affected and what operation failed (API call, download, etc.)

  2. Look at the squid container logs for denied requests:

    docker compose logs --timestamps squid | grep TCP_DENIED
  3. Add the missing domain to the appropriate squid.conf:

    • Exact domain: acl allowed_domains dstdomain example.com
    • All subdomains: acl allowed_domains dstdomain .example.com (note the dot)
  4. Reload squid:

    # restart squid
    docker compose restart squid
    # or reload squid without restart
    docker compose exec squid squid -k reconfigure

Why This Matters

Blocking internet at the network level is trivial with Docker’s internal: true. The hard part is making services work that were designed to reach out constantly. What I’ve built here is egress filtering — a security pattern where all outbound connections are blocked by default, and only whitelisted destinations are allowed through.

By using squid as a proxy with strict domain whitelists, I get both: tight control for security and enough flexibility for services to actually function. The tradeoff is maintenance — every time you add a new service feature that needs external access, you need to update the whitelist. But that’s a good problem to have. Every denied connection is a potential exfiltration path closed off by design.