GICT knowledge base
How to Configure UFW Firewall for Docker Containers
Published
You enable UFW, allow SSH and web traffic, and block everything else. Then a Docker container on port 8080 is still reachable. Docker forwards published-port traffic through a different path from the host connections UFW normally filters. In our test, port 8080 returned HTTP 200 despite an explicit UFW deny rule.
For a web application, a straightforward fix is to bind the container to localhost and put nginx on the host in front of it. UFW then controls the public nginx listener. The commands below were tested on Ubuntu 24.04.4 KVM VMs with Docker 29.1.3, Compose 2.40.3, and UFW 0.36.2, including requests from a separate machine.
What this setup changes
The request path becomes:
Visitor → UFW → host nginx on port 80 → container on 127.0.0.1:8080.
The container serves the application, but its backend port has no public host binding. nginx must run on the host, not in another container with a broadly published port.
This guide covers rootful Docker with ordinary NAT bridge networking. It does not make UFW control every Docker port. Leave Docker’s firewall management enabled; disabling it can break container networking.
Prepare a fresh Ubuntu server
Use a dedicated test server with sudo access and SSH on port 22. If SSH uses another port, substitute it below.
Inspect the existing rules and listeners:
$ sudo ufw status verbose
$ sudo ss -lntp
Install the packages from Ubuntu’s repositories:
$ sudo apt-get update
$ sudo apt-get install -y docker.io docker-compose-v2 nginx curl ufw
$ sudo docker version --format '{{.Server.Version}}'
29.1.3
Use Docker 28 or later. Docker documents a localhost-publishing issue in older releases affecting other machines on the same network segment. If Docker is already installed from Docker’s own repository, keep that installation instead of mixing it with Ubuntu’s docker.io package.
Allow SSH and the public web listener before enabling UFW:
$ sudo ufw allow 22/tcp
$ sudo ufw allow 80/tcp
$ sudo ufw default deny incoming
$ sudo ufw default allow outgoing
$ sudo ufw --force enable
Existing rules remain in place. Review them rather than using ufw reset. No allow rule is needed for port 8080.
Start a localhost-only container
Create the project directory:
$ mkdir -p ~/ufw-docker-demo/site
$ cd ~/ufw-docker-demo
Create ~/ufw-docker-demo/site/index.html with this content:
Docker backend reached
Create ~/ufw-docker-demo/compose.yaml with these contents. Keep the indentation as shown:
services:
web:
image: nginx:1.28-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
Save both files, then start the container from ~/ufw-docker-demo:
$ sudo docker compose up -d
$ sudo docker compose port web 80
127.0.0.1:8080
The critical difference is 127.0.0.1:8080:80, not 8080:80. Confirm that the backend answers locally:
$ curl -fsS http://127.0.0.1:8080/
Docker backend reached
For an existing application, change its port binding in its own Compose file and recreate it with docker compose up -d. Retain its volumes, environment, and other settings; this example is not a replacement for an application’s complete deployment configuration.
Configure the host reverse proxy
Inspect and move the fresh nginx installation’s default-site link out of the enabled directory. Its original configuration remains available:
$ sudo cat /etc/nginx/sites-enabled/default
$ sudo mv /etc/nginx/sites-enabled/default /etc/nginx/default-site.before-ufw-demo
Create /etc/nginx/conf.d/ufw-docker-demo.conf with these contents, using administrator privileges:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Save the file, then check the nginx configuration:
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
On this fresh server, restart nginx after that check succeeds:
$ sudo systemctl enable --now docker nginx
$ sudo systemctl restart nginx
$ curl -fsS http://127.0.0.1/
Docker backend reached
The page now passes through host nginx. This example uses HTTP to isolate the firewall test. Before serving logins or sensitive data, configure a domain and valid TLS certificate on host nginx, allow port 443 in UFW, and redirect HTTP to HTTPS. Certificate issuance is outside this tutorial.
Verify access from another machine
On your laptop or a separate server, enter the Docker host’s public IPv4 address at the prompt:
$ read -r -p 'Docker host public IPv4: ' SERVER_IP
$ curl --noproxy "*" -sS --connect-timeout 3 --max-time 5 "http://$SERVER_IP/"
$ curl --noproxy "*" -sS --connect-timeout 3 --max-time 5 "http://$SERVER_IP:8080/"
The first request should print Docker backend reached. The second should fail to connect. Any HTTP response means something still answered on the backend port. A timeout alone is not proof of UFW filtering: an upstream firewall or unavailable service can also cause it.
On this dedicated test server, briefly block the proxy to verify UFW’s role. This interrupts HTTP access:
$ sudo ufw delete allow 80/tcp
$ sudo ufw deny 80/tcp
$ sudo ufw status numbered
Repeat the external port-80 request. It should fail, while curl -fsS http://127.0.0.1:8080/ on the Docker host still succeeds. Restore access immediately:
$ sudo ufw delete deny 80/tcp
$ sudo ufw allow 80/tcp
Check the external page again. In our run, inserting a deny before the existing allow returned “Skipping inserting existing rule” and left access unchanged. Removing the allow before adding the deny produced the expected block.
| Tested configuration | External port 80 | External port 8080 |
|---|---|---|
| Public Docker binding with UFW denying 8080 | Not tested in baseline | HTTP 200 |
| Localhost binding and host proxy | HTTP 200 | Connection failed |
| UFW deny on the host proxy | Connection failed | Not publicly bound |
The successful requests returned our marker page, not nginx’s unrelated welcome page.
If containers cannot reach the internet
Check outbound access separately; an incoming page does not prove it:
$ cd ~/ufw-docker-demo
$ sudo docker compose exec --interactive=false -T web curl -4 -fsS --connect-timeout 5 --max-time 10 -o /dev/null -w 'Container HTTPS: %{http_code}\n' https://www.ubuntu.com/
Our TeamCompute VMs had private and public addresses on one interface. Requests using the private source timed out; those using the public source succeeded. Docker’s default masquerading needed an explicit source address on that layout. This is conditional, not a required change on every VPS.
Inspect the address selected for an internet route:
$ ip -4 route get 1.1.1.1
If your host has the same source-address problem, confirm the route’s src is the assigned address that can reach the internet. Create ~/ufw-docker-demo/.env with this line, replacing 203.0.113.10 with that address:
EGRESS_IP=203.0.113.10
The address above is an example, not a working destination. If .env already exists, add or update only the EGRESS_IP line; keep its other settings.
In ~/ufw-docker-demo/compose.yaml, add the following block at the top level, aligned with services:. Keep the existing services: block unchanged. If a networks: section already exists, merge the setting into it instead of adding a second section:
networks:
default:
driver_opts:
com.docker.network.host_ipv4: "${EGRESS_IP}"
Recreate the network and container during a maintenance window. This stops the sample app but retains its bind-mounted page:
$ sudo docker compose config --quiet
$ sudo docker compose down
$ sudo docker compose up -d
Repeat the outbound curl check. Our result was:
Container HTTPS: 301
The response confirms DNS resolution, connection, and certificate validation succeeded; 301 is the site’s redirect. This is Docker’s documented source-NAT option, not a reason to open the backend port.
Check persistence
During a maintenance window, reload UFW and restart Docker, then repeat both external requests:
$ sudo ufw reload
$ sudo systemctl restart docker
Our public-page and blocked-backend checks still passed after a UFW reload, Docker restart, and full VM reboot. Test a reboot on your own server before relying on unattended recovery. Docker and nginx are enabled at boot, and Compose uses restart: unless-stopped. A deliberately stopped container will remain stopped; that policy is not a health check or an application-readiness guarantee.
When this approach is not the right fit
This layout suits HTTP applications. A database, VPN, or game server needing direct client connections requires a different access policy. Docker documents the DOCKER-USER chain for its iptables backend; native nftables mode uses a different mechanism. Neither is implemented by this tutorial.
If only local processes need the application, omit nginx and keep the port local. Swarm, host networking, and explicitly enabled direct routing also need their own review; localhost publishing is not a universal isolation guarantee for those configurations.
Gotchas worth knowing
- Check the actual binding. The tested Compose output was
127.0.0.1:8080; a running container alone proves nothing about public exposure. - Use an outside-network test. Our second VM could reach the private address, but its public-address request timed out even before the fix. The outside-network baseline exposed the bypass.
- Read the resulting rules. Inspect
ufw status numberedafter a change instead of assuming the intended rule appeared. - Test IPv6 separately. Our VMs had no routable IPv6 addresses. nginx listens on IPv6 too, so test it separately if your server has public IPv6.
- Update the source address after an IP change. If you needed the optional network setting, a stale
EGRESS_IPcan break outbound connections after a migration.
Wrapping up
A UFW deny did not stop the original publicly published port: it returned HTTP 200. Binding that port to localhost and moving the public listener to host nginx made the access policy testable. Keep the backend private, verify the proxy from another machine, and check outbound traffic as well as incoming requests.
Related guides
How to Find Services That Need a Restart After Updates with needrestart
Find services using outdated libraries with needrestart on Ubuntu. Read restart reports, review automatic restart policy and plan maintenance safely.
Firewall and SecurityHow to Connect Two Servers Privately with WireGuard
Connect two servers with WireGuard and keep PostgreSQL on the tunnel. Verify peer traffic, private database access and denied public connections.
Firewall and SecurityInterServer HIPAA Hosting: BAA and Shared Responsibilities
Review InterServer HIPAA hosting responsibilities and download the BAA and provisioning checklist. Request service-specific coverage through a support ticket.
Firewall and SecurityHow to Sign and Verify Files with Minisign
Sign release files with Minisign, share an authenticated public key, verify downloads, and stop processing when signatures fail.
