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 |
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.”
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.”
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:
- Do not expose the port to the outside at all —
127.0.0.1:1080:1080, as above. Then the firewall question for this service simply doesn’t arise. - If you still need to publish everything — filter in the
DOCKER-USERchain, 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 thesocks5h://scheme (or the--socks5-hostnameflag 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.
Checklist before leaving the proxy running:
docker compose config— check what variables were deployed and ensure the password isn’t emptyss -tlnp | grep 1080on the server — listening on127.0.0.1, not0.0.0.0nc -vz <public_ip> 1080from another machine — no connectioncurl -x socks5h://user:pass@127.0.0.1:1080 https://ifconfig.co— returns the server’s IPcurl -x socks5h://127.0.0.1:1080without a password — denieddocker compose logs --tail=50 socks5— logs show your connections and nothing else
About security — short and to the point
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:
- Authentication always.
REQUIRE_AUTH: "true"and a non-empty password. No “a couple of hours for testing” — test configurations live for years. - Port not on the internet. Publish on
127.0.0.1or on a VPN interface address, and access from outside only via SSH or WireGuard. - Source restriction.
ALLOWED_IPSin the variables plus a rule inDOCKER-USERif the port is still published. - 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.
- Password not in git.
.envin.gitignore, permissions600.
Sources
- Docker: Port publishing — “Publishing container ports is insecure by default”, syntax
-pwith an address, note about versions before 28.0.0 - Docker: Packet filtering and firewalls — why ufw doesn’t see container traffic, the
DOCKER-USERchain - Docker Compose: services reference —
healthcheckfields andrestartvalues - serjs/socks5-server — variables
REQUIRE_AUTH,PROXY_USER,ALLOWED_IPS,ALLOWED_DEST_FQDN - tarampampam/3proxy-docker — image
ghcr.io/tarampampam/3proxy:2, ports and variables
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?

