UFW can report a default-deny incoming policy while a Docker application remains reachable from the internet. A mapping such as 8080:80 publishes a container port through Docker's networking rules; it does not mean UFW has approved that access.
For a backend that only a reverse proxy on the host should reach, explicitly bind the published port to 127.0.0.1. For services used only by other containers, consider removing the host publication entirely. First establish who needs access, then verify the resulting behavior from a separate machine.
Why UFW and Docker disagree
Docker's bridge networking uses forwarding and address translation. With the iptables backend, an incoming connection to a published port can be translated and forwarded toward the container instead of following the host-input path administrators commonly protect with UFW. Docker documents this UFW incompatibility.
That explains why adding another ordinary UFW deny rule may not change the result. It also explains why the UFW status display alone cannot establish whether a container is publicly reachable. Ubuntu's UFW documentation describes ufw status verbose as a configuration check; external connectivity requires a separate test.
OWASP's Docker security guidance independently warns about accidental exposure through published ports. Authentication, patching and careful network placement still matter after closing the unwanted mapping.
Scope and preparation
This procedure targets a rootful Docker Engine installation on Ubuntu with ordinary bridge networking and the Docker Compose plugin. The example is an existing HTTP service named web, listening on container TCP port 80 and published as host port 8080. Substitute your service name and ports.
It is not a procedure for Swarm ingress, host networking, rootless networking or custom routed container networks. Those need their own exposure review. Keep a working SSH session and provider console access available. Schedule a brief interruption: changing a published binding requires recreating the affected container.
The commands below are documented examples, not a report of tests against your server. Back up application data before recreation, confirm persistent data uses the intended volumes or bind mounts and keep the current image reference unchanged.
1. Inventory the running mappings
On the VPS, start with read-only checks:
sudo ufw status verbose
docker ps --format 'table {{.Names}}\t{{.Ports}}'
The first command shows UFW's policy and configured rules. The second uses Docker's container-list formatting to identify published ports. A mapping containing 0.0.0.0:8080->80/tcp targets all IPv4 host addresses. A corresponding [::]:8080 mapping warrants an IPv6 check too.
A bare entry such as 80/tcp, without a host-port arrow, is not by itself a host publication. Do not confuse an image's exposed-port metadata with an internet-facing listener.
For a particular container, replace CONTAINER_NAME with its actual name:
docker inspect --type container CONTAINER_NAME --format '{{json .NetworkSettings.Ports}}'
docker port CONTAINER_NAME
Docker inspect lets you restrict the output to the port-binding data. Docker port provides another view of the published mappings. Record every unexpected publication, including databases, dashboards and metrics endpoints. Prioritize services that lack authentication or hold sensitive data.
A wildcard mapping is evidence of configuration that needs review, not proof that every internet client can connect. A provider firewall or another network control may already block the path. Conversely, no mapping in this output does not rule out exposure through host networking or custom routing.
2. Check access from outside the VPS
Use a separate machine you control. For this HTTP example, replace the documentation address below with the VPS's actual public IPv4 address:
curl --noproxy '*' --connect-timeout 5 --max-time 10 -v http://203.0.113.10:8080/
Any HTTP response, including a 401, 403 or 404, shows that this client reached an HTTP service at that address and port. A timeout or refusal establishes only that this particular attempt did not connect. It does not identify which layer blocked it.
If the VPS has public IPv6, test its actual IPv6 address separately:
curl --noproxy '*' --connect-timeout 5 --max-time 10 -g -6 -v 'http://[2001:db8::10]:8080/'
Both addresses are placeholders. The curl manual documents the timeout, IPv6 and proxy controls used here. Bypassing a configured client proxy helps ensure the request tests the intended direct path.
Do not run the external check only from the VPS itself. Also avoid testing solely through a CDN hostname: a protected website hostname and its origin IP can take different paths. These curl examples test TCP HTTP, not UDP or a database protocol.
3. Restrict the Compose publication
For this example, work in the existing deployment directory with its compose.yaml. If your deployment uses a different filename, multiple -f files, profiles or a project name, preserve those choices in every command below. Do not accidentally create a second project.
Save a copy before editing:
cp -p compose.yaml compose.yaml.before-port-binding
Use a fresh backup filename if that copy already exists. Replace the existing web service's 8080:80 entry with:
services:
web:
ports:
- "127.0.0.1:8080:80"
This is a fragment to merge into your existing service, not a replacement Compose file. Keep its image, environment, networks and mounts. Remove the old wildcard entry rather than adding a second mapping beside it. Docker's Compose ports reference defines the host-IP, host-port and container-port syntax.
Validate the complete configuration:
docker compose -f compose.yaml config --quiet
Successful validation is silent. Stop if it returns an error. For deployments with overrides, also inspect the effective ports configuration: Compose config merges files and resolves variables. An override may retain a mapping you thought you removed. Treat rendered configuration as sensitive because it may include credentials.
Apply the change only to the intended service:
docker compose -f compose.yaml up -d --no-deps --force-recreate --no-build --pull never web
docker compose -f compose.yaml ps --all web
The Compose up options recreate web without starting its dependencies, building an image or pulling one. The required image must already be local. Mounted volumes are preserved, but data stored only in the old container's writable layer is not a substitute for persistent storage. Do not use down -v for this change.
Compose ps shows whether the replacement is running or has exited. Check application health as well; a running container alone is insufficient.
4. Verify the permitted path and the blocked path
From the VPS, the backend should still respond:
curl --noproxy '*' --connect-timeout 5 --max-time 10 -v http://127.0.0.1:8080/
Inspect the replacement container's mappings again. For this example, expect host port 8080 to be bound to 127.0.0.1, with no remaining wildcard publication for that backend. Repeat the public IPv4 and IPv6 requests from the separate machine; they should no longer reach it. Finally, verify the actual public website through its intended proxy and TLS endpoint.
Docker's port-publishing documentation notes an important version boundary: releases before 28.0.0 can allow hosts on the same layer-2 segment to reach localhost-published ports. Use a maintained Engine version containing that fix. Also review direct-routing and gateway-mode settings before treating a loopback mapping as the complete isolation policy.
When the proxy is another container
Inside a proxy container, 127.0.0.1 refers to that container, not the VPS host. Changing its upstream to 127.0.0.1:8080 therefore does not reproduce a host-installed proxy setup.
For a containerized proxy and backend, use an appropriate shared Docker network and address the backend by service name and container port. If no host process needs the backend port, remove its ports entry. Compose's expose field is not a host publication and is not an access-control list; review which containers share the network.
For a private administrative HTTP endpoint, an SSH tunnel is another option. Run this on your workstation, using your real SSH account and VPS hostname:
ssh -N -L 127.0.0.1:18080:127.0.0.1:8080 admin@vps.example.com
Then open http://127.0.0.1:18080/ locally. The OpenSSH manual documents local forwarding: the destination connection originates on the remote side. Server policy must permit forwarding. Keep application authentication enabled and stop the tunnel when finished.
Troubleshooting and recovery
If the host-local request fails, inspect container status and application logs before changing firewalls. Confirm the application listens on container port 80 and that the Compose service was recreated. If the host-local request succeeds but the website returns a gateway error, inspect the proxy's network location and upstream address.
If public access still works, check the replacement container rather than the old name, effective Compose overrides, other containers publishing the same application and both address families. An upstream firewall can provide immediate containment while you investigate, provided the rule is scoped to the backend port and preserves your management access.
If you must restore the prior Compose configuration, remember that the old file may republish the backend publicly. Establish that containment first, or restore only the unrelated configuration that caused the failure. For a full configuration rollback:
cp -p compose.yaml.before-port-binding compose.yaml
docker compose -f compose.yaml config --quiet
docker compose -f compose.yaml up -d --no-deps --force-recreate --no-build --pull never web
Run the final command only after validation succeeds. Recheck application health and external reachability. Restoring a configuration is not evidence that the original exposure has been resolved.
If the service must remain public
A game server or public API may require direct inbound connections. In that case, localhost binding would break the intended service. Define permitted sources and protocols and enforce them through the appropriate firewall path.
For Docker's iptables backend, DOCKER-USER is the documented place for user filtering before Docker's forwarding rules. Destination translation has already occurred there, which matters when matching ports. Docker's native nftables backend has no equivalent named chain; it requires separate tables and suitable hook priorities. The current documentation still labels that backend experimental. The iptables-nft compatibility frontend and Docker's native nftables backend are different concepts.
Do not copy rules for one backend into another or disable Docker's firewall management as a shortcut. Docker warns that disabling its rule creation can break container networking. For broader context, continue with our Ubuntu VPS firewall guide and Linux game-host network hardening guide. Review the actual published mappings and external connectivity again after future deployment changes.

