SOCKS5 in Docker: a line-by-line analysis of the docker-compose file and four gotchas

Bringing up SOCKS5 in Docker is one command. Bringing up SOCKS5 in Docker so that no one else will be using it after a day is roughly twenty lines, each of which means something. The difference between these two outcomes is the content of the article.

Below is a ready docker-compose.yml, broken down line by line, and four traps that almost everyone falls into: from a port opened to the internet by carelessness, to losing SSH access to your own server.

If you need a container-free option — there is a separate analysis on ssh -D, Dante, and 3proxy on a bare server: отдельный разбор.


Which to bring up: three candidates

Image What’s inside Authentication Features
serjs/go-socks5-proxy SOCKS5 server on Go REQUIRE_AUTH (default true), PROXY_USER / PROXY_PASSWORD has ALLOWED_IPS and ALLOWED_DEST_FQDN — filter by source and by destination domain regex
ghcr.io/tarampampam/3proxy:2 3proxy PROXY_LOGIN / PROXY_PASSWORD SOCKS on 1080 and HTTP proxy on 3128 simultaneously, MAX_CONNECTIONS (512 by default), own resolvers
your own image with Dante sockd system users, PAM maximum control, but you’ll have to mount the config as a volume and build the image yourself
Info:

For a typical task of “give one or two apps access through this server,” the first one is enough. The second is more interesting if you also need an HTTP proxy on the same container: the current image version is v2.1.0 from June 16, 2026, default ports 3128/tcp (HTTP) and 1080/tcp (SOCKS), resolvers 1.0.0.1 and 8.8.4.4 are set via variables PRIMARY_RESOLVER and SECONDARY_RESOLVER.


The full Compose file

services:
  socks5:
    image: serjs/go-socks5-proxy
    container_name: socks5
    restart: unless-stopped

    environment:
      REQUIRE_AUTH: "true"
      PROXY_USER: "socksuser"
      PROXY_PASSWORD: "${SOCKS_PASSWORD:?variable not set}"
      PROXY_PORT: "1080"
      ALLOWED_IPS: "203.0.113.10,203.0.113.11"

    ports:
      - "127.0.0.1:1080:1080"

    healthcheck:
      test: ["CMD-SHELL", "nc -z 127.0.0.1 1080 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    read_only: true
    tmpfs:
      - /tmp

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

The password sits in .env next to the compose file, not in the compose itself:

echo "SOCKS_PASSWORD=$(openssl rand -base64 24)" > .env
chmod 600 .env

The construct ${SOCKS_PASSWORD:?...} is not decoration: without it an empty variable will silently turn into an empty password, and the container will fail to start. With it, docker compose up will refuse to start and will print the reason.

What each block does

restart: unless-stopped. Of the four restart policy values (no, always, on-failure, unless-stopped) this is the one that suits a service that should survive a server reboot but stay off if you turned it off manually. always will bring the container up even after your conscious docker stop — at the next daemon start.

environment. ALLOWED_IPS — a comma-separated list of sources. This is a filter inside the proxy itself; it does not replace a firewall, but it blocks the scenario: “the port is open by mistake, and the login was guessed.”

ports: "127.0.0.1:1080:1080". The most important line of the file. The breakdown is below, in Trap 1.

healthcheck. A set of compose fields: test, interval, timeout, retries, start_period and start_interval. The nc -z check confirms the port accepts connections — not exactly “the proxy is working,” but catches the most common failure: the process crashed, the container is alive. start_period gives the container time to start without counting failed attempts.

security_opt, cap_drop, read_only. SOCKS proxy doesn’t need privileges or write access to its filesystem. Three lines — and the consequences of a hypothetical hole in the proxy itself are greatly reduced.

logging. Without a limit, the json-file grows until the disk fills. A proxy is exactly the service that writes a line for every connection.


Trap 1. -p 1080:1080 opens a port to the Internet

Docker’s documentation states this explicitly: “Publishing container ports is insecure by default. Meaning, when you publish a container’s ports it becomes available not only to the Docker host, but to the outside world as well.”

That means ports: - "1080:1080" is not “forward the port inside the host.” It means “listen on 0.0.0.0:1080.” An open SOCKS5 on the standard port will be found by scanners, and it’s a matter of hours, not weeks.

The correct form is with an explicit address:

ports:
  - "127.0.0.1:1080:1080"     # only the host itself
  # - "10.8.0.1:1080:1080"    # only via VPN interface

Documentation: “If you include the localhost IP address (127.0.0.1, or ::1) with the publish flag, only the Docker host can access the published container port.”

Warning:

A note from the same documentation: in Docker versions prior to 28.0.0, hosts in the same local network segment could reach ports published on localhost, contrary to the expected behavior. Check docker version — if your server is on an older branch, don’t rely on 127.0.0.1 as the sole protection.

So how to use the proxy from a laptop if it listens only on the server’s loopback? Via SSH:

# on the client: local 1080 → 127.0.0.1:1080 on the server
ssh -L 127.0.0.1:1080:127.0.0.1:1080 -N user@vps.example.com

You get two tunnels instead of one, but nothing is exposed publicly. An alternative is to bind the published port to a WireGuard interface address and route through a VPN.


Trap 2. UFW does not close container ports

Classic: the administrator runs ufw deny 1080/tcp, checks ufw status, sees DENY — and the port still answers from outside.


Diagram based on Packet filtering and firewalls | Docker Docs

Official explanation: “When you publish a container’s ports using Docker, traffic to and from that container gets diverted before it goes through the ufw firewall settings.” The technical reason: “Docker routes container traffic in the nat table, which means that packets are diverted before it reaches the INPUT and OUTPUT chains that ufw uses.” The result — “Packets are routed before the firewall rules can be applied, effectively ignoring your firewall configuration.”

Error:

Do not treat ufw status as proof that the port is closed. The only reliable check is to try to connect from another machine:

nc -vz vps.example.com 1080

Connection refused or timeout — good. succeeded — bad, regardless of what ufw shows.

There are two practical options, and the first is preferable:

  1. Do not expose the port to the outside at all127.0.0.1:1080:1080, as above. Then the firewall question for this service simply doesn’t arise.
  2. If you still need to publish everything — filter in the DOCKER-USER chain, which is processed before Docker rules, or use the well-known ufw-docker rule set. This is discussed separately on the forum: UFW-Docker: how to close Docker container ports and set up ufw firewall.

Trap 3. DNS resolves where you don’t expect

The container gets resolvers from Docker (by default — the built-in 127.0.0.11, which proxies to the host’s resolvers). The proxy server inside the container will resolve names through them.

Next comes confusion from two layers:

  • Client layer. If the client uses the socks5:// scheme, it resolves the name itself and passes the proxy an IP. Everything configured inside the container doesn’t matter. You need the socks5h:// scheme (or the --socks5-hostname flag in curl) — then the proxy resolves the name.
  • Container layer. After this, it matters which resolvers the container uses. If the server is located where the local DNS responds with spoofed addresses, the proxy will faithfully return the same spoofed address.

Explicitly set resolvers:

services:
  socks5:
    dns:
      - 1.1.1.1
      - 9.9.9.9

Check that the server actually resolves the name, not the client — on the server:

# in one terminal
sudo tcpdump -i any -n port 53
# in another, from the client
curl -x socks5h://socksuser:PASS@127.0.0.1:1080 https://ifconfig.co

If a DNS request to port 53 appears — it means it’s resolving through the proxy. If it doesn’t appear — you forgot the h in the scheme.


Trap 4. How not to cut off access to your server

Three most common ways to end up outside your own machine:

Firewall rule first, then check. ufw deny incoming without first running ufw allow OpenSSH — and that’s it. The order is always: first allow SSH, then close everything else, and only then ufw enable.

network_mode: host “to make it easier”. The container starts listening on all host interfaces, ports stop working entirely, isolation disappears. There is no need for SOCKS5 in this: a regular bridge network is enough. There is a detailed discussion about Docker network modes on the forum: detailed breakdown.

Testing rules without risk. Before experimenting with a firewall on a remote server, it’s useful to set a delayed rollback:

# after 10 minutes the rules will rollback automatically unless you cancel the timer
sudo bash -c 'echo "ufw --force reset && ufw allow OpenSSH && ufw --force enable" | at now + 10 minutes'

Make sure the access is alive — then cancel the job via atrm. If not, the server will fix itself.

Success:

Checklist before leaving the proxy running:

  • docker compose config — check what variables were deployed and ensure the password isn’t empty
  • ss -tlnp | grep 1080 on the server — listening on 127.0.0.1, not 0.0.0.0
  • nc -vz <public_ip> 1080 from another machine — no connection
  • curl -x socks5h://user:pass@127.0.0.1:1080 https://ifconfig.co — returns the server’s IP
  • curl -x socks5h://127.0.0.1:1080 without a password — denied
  • docker compose logs --tail=50 socks5 — logs show your connections and nothing else

About security — short and to the point

Security:

An open proxy is not about who uses it, but about who owns the IP. Through your container, external connections will originate from your address: spam, brute-forcing passwords to other panels, scanning, downloading what will bring an abuse complaint. You are responsible to your host provider, and “I didn’t know the port was open” is not an argument; in the worst case, the machine will be shut down without warning.

What is mandatory, not optional:

  1. Authentication always. REQUIRE_AUTH: "true" and a non-empty password. No “a couple of hours for testing” — test configurations live for years.
  2. Port not on the internet. Publish on 127.0.0.1 or on a VPN interface address, and access from outside only via SSH or WireGuard.
  3. Source restriction. ALLOWED_IPS in the variables plus a rule in DOCKER-USER if the port is still published.
  4. Logs readable by someone. Limited in size, but not disabled. Someone else’s IP in the logs is the only signal that someone found the proxy.
  5. Password not in git. .env in .gitignore, permissions 600.

Sources

Question:

A final question to those who run proxies in containers: do you restrict outbound connections from the container itself? A proxy by definition goes wherever it wants, and the rule “only 80/443 to the outside” breaks many scenarios — but also makes the container a far less interesting target. Who has found a workable compromise?