A firewall on a single server is one decision, repeated: nothing gets in unless you said so. UFW - the Uncomplicated Firewall - is the tool that makes that decision cheap to express on Debian and Ubuntu, and five commands set it up. Everything interesting comes afterwards: the rule for a game server's UDP pair, what limit actually does to SSH, why a rule you added is being ignored, and the fact that publishing a Docker port walks straight past every rule in this post. That last one catches people who did everything else correctly.
If you want the short version and you are sitting on a new box right now: sudo ufw default deny incoming, sudo ufw default allow outgoing, sudo ufw allow OpenSSH, sudo ufw enable. Then read the rest before you add anything else, because the order in which you do those four things is the difference between a locked-down server and a server you cannot log into.
What UFW is, and what it is not#
UFW is not a firewall. The firewall is netfilter, inside the Linux kernel, and it has been there the whole time with an empty policy. UFW is a command-line front end that writes rules into it - as iptables rules, which on current Debian and Ubuntu are translated to nftables by the iptables-nft backend. When you run ufw allow 443/tcp you are not inventing anything; you are adding one line to a chain called ufw-user-input and letting UFW manage everything around it.
That surrounding structure is the real product. A hand-written iptables ruleset has to remember to permit loopback traffic, to accept packets belonging to connections you opened yourself, to handle ICMP sensibly, and to do all of it again for IPv6. UFW ships that as before.rules and after.rules, puts your rules in the middle, and keeps the two address families in step. The files live in /etc/ufw/, and the ones you will eventually care about are /etc/ufw/before.rules, /etc/ufw/after.rules and /etc/default/ufw.
What UFW is not:
- It is not protection against volumetric attacks. A flood large enough to fill the uplink has already cost you the bandwidth by the time the kernel drops it. Filtering that has to happen upstream of the machine. DDoS attacks on game servers explained is blunt about what can and cannot be done at each layer.
- It is not an application firewall. It matches addresses, ports and protocols. It has no idea whether a request to port 443 is a login attempt or a login attempt repeated nine thousand times - that is fail2ban reading your logs, working alongside UFW rather than instead of it.
- It is not a reason to run something insecure. A closed port in front of an unpatched service buys you time, not safety.
- It does not filter traffic between containers, and it does not necessarily filter traffic into them either. See the Docker section, which is the longest one here for a reason.
The first five minutes on a new box#
Ubuntu ships UFW installed and inactive. Debian needs apt install ufw. The order below matters: set the defaults, allow your way back in, and only then switch it on.
$ sudo apt update && sudo apt install -y ufw$ sudo ufw default deny incoming$ sudo ufw default allow outgoing$ sudo ufw allow OpenSSH$ sudo ufw enableUFW prompts before it cuts you off - Command may disrupt existing ssh connections. Proceed with operation (y|n)? - and that prompt is the last warning you get. The setting persists across reboots: ufw enable writes ENABLED=yes into /etc/ufw/ufw.conf and enables the ufw systemd unit, so the rules are back before the network is.
Check what you have:
$ sudo ufw status verboseStatus: activeLogging: on (low)Default: deny (incoming), allow (outgoing), disabled (routed)New profiles: skipTo Action From-- -- ----22/tcp (OpenSSH) ALLOW IN Anywhere22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)The Default: line is the one to read. deny (incoming) is the whole point; allow (outgoing) is the sane default for a server you control, because locking down outbound traffic on a box that has to fetch packages, pull images and talk to APIs is a project rather than a command. disabled (routed) means UFW is not filtering forwarded traffic, which is exactly the gap Docker drives through later.
Writing rules: both syntaxes#
UFW has a short form for the common case and a long form for everything else. They produce the same kind of rule.
# Short form: port, optional protocol, optional application profile$ sudo ufw allow 443/tcp$ sudo ufw allow 25565$ sudo ufw allow 'Nginx Full'# Long form: source, destination, port, protocol$ sudo ufw allow from 203.0.113.10 to any port 22 proto tcp$ sudo ufw allow from 10.0.0.0/8 to any port 5432 proto tcp$ sudo ufw deny from 198.51.100.0/24A few points that are not obvious from the help text:
- Omitting the protocol opens both.
ufw allow 25565creates a TCP rule and a UDP rule. Be specific unless you mean both; extra open UDP ports are how a server ends up reflecting somebody else's attack traffic. - Every rule is created for IPv4 and IPv6 unless you name an address family by giving an address. If the box has a public IPv6 address - check with
ip -6 addr- then a v4-only rule leaves a v4-only hole in an otherwise open door. - `deny` drops silently, `reject` answers. A dropped packet makes the port look like it belongs to a machine that is switched off; a rejected one gets an ICMP port-unreachable or a TCP reset, which is faster and more honest for internal services. Use
denyfacing the internet andrejectwhere a quick failure is more useful than a stealthy one. - Comments are supported and worth the typing.
sudo ufw allow 8443/tcp comment 'matrix bridge'shows up inufw statusand saves you from the six-month-old rule nobody can explain.
Application profiles come from /etc/ufw/applications.d, and packages drop them there when you install them. ufw app list shows what is available; ufw app info 'Nginx Full' prints the ports behind a name. They are a convenience, not a feature - Nginx Full is 80,443/tcp and nothing more.
Rules for what you will actually run#
| What you are running | Rule | Note |
|---|---|---|
| SSH | ufw limit 22/tcp | Rate-limited, see below |
| A website through nginx | ufw allow 'Nginx Full' | Port 80 must stay open for ACME |
| Minecraft Java | ufw allow 25565/tcp | TCP only, despite the folklore |
| Valheim | ufw allow 2456:2457/udp | Game port and query port, both UDP |
| A Source game | ufw allow 27015/udp | Add 27015/tcp only if you use RCON |
| PostgreSQL | ufw allow from 203.0.113.10 to any port 5432 proto tcp | Never allow 5432 on its own |
| A Node app behind a proxy | no rule at all | Bind it to 127.0.0.1 instead |
Port ranges use a colon and must name a protocol: ufw allow 27015:27020/udp works, ufw allow 27015:27020 is rejected. The long form spells the same thing out as ufw allow proto udp from any to any port 27015:27020.
The last two rows are the ones worth internalising. A database or an application server that only ever talks to something on the same machine should be bound to loopback, at which point no firewall rule is needed because the packet can never arrive from outside. A firewall rule protecting a service that should not have been listening publicly is a second lock on a door you left open; firewall rules that matter makes the same argument from the other direction, and game server ports explained covers why games so often need a pair of ports rather than one.
For the specific question of which port each game wants, the pattern is nearly always a game port plus a query port one or two numbers above it, both UDP. Open the pair. A TCP-only rule on a UDP game produces the single most common support ticket in hosting: the server starts fine, the log looks healthy, and nobody can connect or find it in the browser.
Order, deletion and the limit rule#
UFW evaluates rules in order and stops at the first match. This is the source of most "my rule does nothing" confusion. If you add a blanket allow for port 80 and then add a deny for one address on port 80, the deny is below the allow and is never reached.
$ sudo ufw status numberedStatus: active To Action From -- -- ----[ 1] 22/tcp LIMIT IN Anywhere[ 2] 80,443/tcp (Nginx Full) ALLOW IN Anywhere[ 3] 25565/tcp ALLOW IN Anywhere$ sudo ufw insert 1 deny from 198.51.100.7$ sudo ufw delete 4$ sudo ufw delete allow 25565/tcpinsert N puts a rule at a specific position, which is how you get a block in front of an allow. delete N removes by number - and renumbers everything below it, so delete from the bottom up or re-run status numbered between deletions. delete followed by the original rule text also works and is safer in a script.
ufw limit deserves its own paragraph because it is the most useful thing in the tool and the least understood. ufw limit 22/tcp allows the port but denies a source address that has opened six or more connections in the last thirty seconds. On an internet-facing SSH port that removes essentially all of the automated password-guessing noise without you configuring anything. Two caveats: it counts connections, not failed logins, so a legitimate burst of parallel scp jobs can trip it, and it is IPv4-only in older UFW releases - check ufw status for a LIMIT IN entry on the (v6) line before assuming both are covered.
For anything more selective, keep UFW for the coarse decisions and put fail2ban on top for the log-reading ones. And on a server with SSH keys and password authentication disabled, the guessing attempts become noise rather than risk in the first place.
The Docker problem#
This is the part to read twice. Docker bypasses UFW. If you run a container with -p 8080:80, port 8080 is reachable from the internet even though ufw status says deny and shows no rule for it.
The reason is structural rather than a bug. UFW's rules live in the INPUT chain, which handles packets destined for the host itself. A published container port is destination-NATed in PREROUTING and then traverses the FORWARD chain on its way to the container's namespace - it never touches INPUT. Docker installs its own rules in FORWARD to permit exactly that, and Docker's rules are evaluated before UFW gets a say. Your default deny is not wrong; it is being asked about a different journey.
Four ways out, in the order you should consider them:
- Publish to loopback.
-p 127.0.0.1:8080:80binds the published port to the loopback interface, so nothing off the machine can reach it, and a reverse proxy running on the host still can. This is correct on every Docker version, needs no extra tooling, and is the right default for anything that sits behind an nginx reverse proxy. - Do not publish at all. Containers on the same user-defined network reach each other by service name on the container port. Only the front door needs a published port - which is the shape Docker Compose for small stacks builds around.
- Use the `DOCKER-USER` chain. Docker provides this chain specifically as a hook, and it is evaluated before Docker's own rules, so it is the supported place for your own filtering of container traffic.
- Use `ufw-docker`, a community script that adds forward-chain handling so
ufw route allowbehaves the way you expect for containers. It works, it is widely used, and it is one more moving part to understand before you rely on it.
A minimal DOCKER-USER rule, restricting published container ports to one address, looks like this:
$ sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP$ sudo iptables -L DOCKER-USER -n --line-numbersTwo footnotes. Rules added with iptables by hand do not survive a reboot unless you persist them - iptables-persistent on Debian and Ubuntu, or a small systemd unit as described in systemd services for your apps. And Docker's behaviour in this area has been tightened in recent major releases, particularly around direct access to container addresses, so the exact symptoms vary with your version. Rather than track which release does what, adopt habit one: publish to 127.0.0.1 unless the port genuinely belongs to the public. That is correct everywhere.
IPv6, forwarding and logging#
Three switches live in /etc/default/ufw, and all three matter on a real server:
IPV6=yesDEFAULT_INPUT_POLICY="DROP"DEFAULT_OUTPUT_POLICY="ACCEPT"DEFAULT_FORWARD_POLICY="DROP"IPV6=yes is the default on current releases and should stay that way. If your VDS has a routable IPv6 address, an IPv4-only ruleset protects half of the machine. Verify with sudo ip6tables -L ufw6-user-input -n that your rules exist in both families, or just read the (v6) rows in ufw status.
DEFAULT_FORWARD_POLICY governs routed traffic - containers, VPNs, anything where the box is a middle hop rather than a destination. ufw default deny routed sets it from the command line, and ufw route allow proto tcp from any to 10.0.0.5 port 80 adds exceptions. After editing the file by hand, run sudo ufw reload.
Logging is off-by-default in the sense that low is quiet. sudo ufw logging medium records blocked packets and new connections to /var/log/ufw.log through syslog, which is enough to answer "is my rule the thing dropping this". It is also enough to fill a small disk on a busy public address, so turn it up while you are debugging and back down when you are done - sudo ufw logging low. Logs worth keeping has the general version of that trade.
Proving it works#
Three checks, from the inside out. First, what is actually listening:
$ sudo ss -lntupNetid State Local Address:Port Processtcp LISTEN 0.0.0.0:22 sshdtcp LISTEN 127.0.0.1:3000 nodeudp UNCONN 0.0.0.0:2456 valheim_server.x86Anything bound to 0.0.0.0 or [::] is exposed to the network and is relying on the firewall. Anything on 127.0.0.1 is not reachable from outside no matter what the firewall says. This one command resolves more arguments than any other in this post.
Second, what the firewall thinks: sudo ufw status verbose, read from the top, remembering first-match-wins. sudo ufw --dry-run allow 8080/tcp prints the iptables lines a rule would generate without applying it, which is useful when you want to see where in the chain it would land.
Third, and the only one that counts, from somewhere else:
$ nmap -Pn -p 22,80,443,3000,8080 203.0.113.10$ nmap -Pn -sU -p 2456,2457 203.0.113.10$ nc -zv 203.0.113.10 25565Scan from another machine, never from the server itself - loopback ignores the rules you are trying to test. If a port you expected to be closed answers, work backwards: is something published by Docker, is there a rule above your deny, is it open on IPv6 only. Doing this once after every change to the stack is a five-second habit that catches the database you exposed while debugging and forgot about.
When it goes wrong#
I enabled UFW and lost SSH. Use the provider's console to get a shell, then sudo ufw allow 22/tcp (or your port) and sudo ufw reload. If you have no console, you have a reinstall. This is the failure this post exists to prevent.
My allow rule does nothing. Something above it matched first. ufw status numbered, then ufw insert the rule higher, or delete the broad rule that is shadowing it.
The port is open but the service is unreachable. The firewall is not your problem. Check ss -lntup for the process, check it is bound to 0.0.0.0 rather than 127.0.0.1, and check the service log.
The port is closed but I allowed it. Check the protocol - a UDP game with a TCP rule is the classic. Check IPv6. Check whether there is a second firewall in front of the machine at the provider level.
A container is reachable and I never allowed it. Docker. See above.
Rules vanished after a reboot. sudo systemctl is-enabled ufw should say enabled. Hand-written iptables rules outside UFW do not persist on their own.
`ufw reset` wiped everything. That is what it does: it disables the firewall and moves the existing rule files to timestamped backups in /etc/ufw/. The old rules are still on disk if you need to read them back.
On a RE:NODE VDS all of this is yours to run, because the plan is a machine with full root access rather than a managed container - the firewall, the kernel and every listening port are under your control and nobody else's. That is the trade: on a managed game or app plan there is no firewall to configure at all, because each plan comes with its port allocations and you add or remove ports on the Network tab. On a VDS you get the flexibility and the responsibility together, and this post is part of the responsibility. The rest of the first day is in the first hour on a new VDS.
FAQ#
Should I change the SSH port to hide it?
It removes a large amount of automated log noise and stops nothing that is aiming at you specifically. Do it if you like quieter logs, but do it as well as keys, limit and fail2ban rather than instead of them. Remember to allow the new port before restarting sshd, not after.
Is UFW enough on its own?
For one server running a handful of services, a default-deny policy with a short allow list is most of the value available at this layer. It does not patch software, it does not stop credential guessing beyond rate limiting, and it cannot help with a flood that fills the link. Treat it as one of four or five things rather than the thing.
Why is my Docker container reachable when UFW says deny?
Because published container ports are forwarded rather than delivered to the host, and Docker's forwarding rules are evaluated before UFW's. Publish to 127.0.0.1 where possible, keep internal services off published ports entirely, and use the DOCKER-USER chain when you genuinely need a public container port restricted by address.
Do I need rules for outgoing traffic?
Rarely, on a single application server. Outbound filtering is valuable on a machine that should never initiate connections, and it is a real project on one that has to fetch packages and call APIs. If you go there, start by logging what leaves before you block anything.
What is the difference between deny and reject?
deny drops the packet with no reply, so the sender waits for a timeout. reject sends an ICMP unreachable or a TCP reset, so the sender fails immediately. Drop facing the internet, reject where fast, clear failures are more useful than silence.
Does UFW work with nftables?
Yes. On current Debian and Ubuntu the iptables commands UFW issues are handled by the iptables-nft translation layer and end up as nftables rules in the kernel. You can inspect the result with sudo nft list ruleset, though the output is considerably less readable than ufw status.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.