How to Secure a Coolify VPS Without Breaking Coolify

I put a fresh Coolify box on a public IP, turned on ufw, allowed only SSH and web traffic, then probed it from my laptop. Three ports answered that I had never allowed. This guide is that measurement, the fix, and the proof that the fix does not cost you the panel, the live logs, the web terminal or your deployments.

Every number below came off that server. This is not theory for me: I run the same stack in production, and it is the stack I teach in my Self Hosting 2.0 course.

TL;DR

Docker opens container ports by writing a DNAT rule (a rule that rewrites where a packet is going) into a table the kernel reads before ufw's rules. So ufw deny 8000 changes nothing. Put the panel on a domain first, then write your policy into the DOCKER-USER chain through /etc/ufw/after.rules. Ports 8000, 6001 and 6002 close, and Coolify keeps working because it now reaches all of that over 443.

The fast path: five steps, in plain words
  1. Knock on your server's doors from your laptop and see which ones answer.
  2. Put the panel on a real domain with HTTPS, so closing the extra doors costs you nothing.
  3. Turn on the normal firewall: allow SSH and web traffic, deny the rest.
  4. Paste one block into one file, so the doors Docker opens obey the firewall too.
  5. Knock again from your laptop and confirm only 22, 80 and 443 answer.

About 15 minutes, every command copy-paste. You do not need to understand Docker's firewall internals to finish: the theory sections below are marked optional, and skipping them is allowed.

New to firewalls? The jargon on this page, translated
Port
A numbered door on your server. Every service answers behind one: SSH behind 22, HTTPS behind 443.
Published port
A door Docker has opened on the server itself so the internet can reach a container. Publishing is what makes a container port public.
ufw
Ubuntu's firewall tool, the doorman at the entrance. It checks visitors against the guest list you wrote, but only for doors that belong to the building itself.
Chain
A list of firewall rules the kernel reads top to bottom, like a guard working through a checklist. The first rule that matches decides.
DNAT
A mail-forwarding order: anything addressed to door 8000, deliver to this container instead. Docker files one for every port it publishes, and it runs before the doorman sees the visitor.
DOCKER-USER
The one chain Docker reserves for your rules and never touches. It is checked before traffic reaches any container. On a stock install it is empty.
Loopback
The machine's private line to itself. Tests you run on the server travel it and never meet the firewall, which is why this guide tests from your laptop.
Reverse proxy
The single service answering doors 80 and 443 that passes each visitor to the right app based on the domain they asked for. Coolify's is called Traefik.
Network interface
The server's network plug. eth0 is usually the one facing the internet, and ip -4 route show default prints yours.
What you'll have at the end
  • A one-command check that tells you what the internet can actually reach, run from your laptop
  • A server answering on 22, 80 and 443 only, verified from outside
  • The dashboard, live logs, the browser terminal and real deployments all still working
  • A default-deny policy that also covers the database you deploy next month, with no new rule
  • A firewall that survives a Docker restart and a reboot, proven, not assumed
Before you start
  • What you need: a VPS running Coolify v4, root SSH access, and a domain you can point at the server. You also need a second machine to test from. Your own laptop is fine.
  • Tested on: Coolify 4.1.2, Ubuntu 24.04, on 29 July 2026.
  • What can go wrong: you are editing a live firewall. The rules in this guide only filter traffic heading to containers, so they cannot cut your SSH session, but the ufw steps can. Before you touch anything, open a second terminal and log in. Leave it open. That is your undo.
⚙ Personalize this guide

Your server IP, panel domain & network interface (optional)

Drop in your server's IP address, the domain you'll put the panel on, and your public network interface. Every copyable block on this page updates, so you can paste with no edits.

Values stay in your browser only. Nothing leaves this page.

First: check what the internet can reach

Do this before you change anything. It takes about a minute and it decides whether the rest of the guide applies to you.

The check has to run from your laptop, not from the server. A test you run on the box talks to itself over loopback, the machine's private line to itself, and that line never crosses the firewall. So the test passes even on a server that is wide open. You have to knock from outside.

Run it in a terminal on your own machine, using the version that matches your system. All three do the same thing: try a plain TCP connection to each port and print whether anything answered. Type your server's IP into the personalize card above and every version below is already filled in.

On Windows, you need nothing installed. Right-click the Start button, open Terminal (or Windows PowerShell), and paste this. Plain cmd cannot make this test on its own, so use PowerShell, which every Windows 10 and 11 machine has:

foreach ($p in 22, 80, 443, 6001, 6002, 8000) {
  if (Test-NetConnection YOUR.SERVER.IP -Port $p -InformationLevel Quiet -WarningAction SilentlyContinue) {
    "$p OPEN"
  } else {
    "$p closed"
  }
}

Expect the PowerShell version to be slow on ports a firewall silently drops: each one takes about 20 seconds to give up, measured on my machine, so scanning a hardened server takes about a minute. The slow ports are the firewall doing its job.

On Linux, and on Windows through WSL or Git Bash if bash is home for you:

for p in 22 80 443 6001 6002 8000; do
  timeout 5 bash -c "exec 3<>/dev/tcp/YOUR.SERVER.IP/$p" 2>/dev/null \
    && echo "$p OPEN" || echo "$p closed"
done

On a Mac, the Linux loop fails, because macOS ships without the timeout command. Use this version instead. It asks the same question through nc, which macOS has:

for p in 22 80 443 6001 6002 8000; do
  nc -z -w 5 YOUR.SERVER.IP $p 2>/dev/null \
    && echo "$p OPEN" || echo "$p closed"
done
Why not curl?

Because curl speaks HTTP and most of these ports do not. On my first pass I used curl and it reported port 22 as unreachable on a server I was actively logged into over SSH. It was not unreachable, sshd just does not answer an HTTP request. A raw TCP connect through /dev/tcp asks the only question that matters here: does anything pick up.

Here is what my freshly hardened server said, next to what ufw on that same server was reporting at that exact moment:

The same server, asked twice

Both outputs were taken within seconds of each other on Coolify 4.1.2.

On the server · what ufw reports

root@server: ~
root@server:~# ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing)
New profiles: skip

To                Action      From
--                ------      ----
22/tcp            ALLOW IN    Anywhere
80/tcp            ALLOW IN    Anywhere
443/tcp           ALLOW IN    Anywhere
22/tcp (v6)       ALLOW IN    Anywhere (v6)
80/tcp (v6)       ALLOW IN    Anywhere (v6)
443/tcp (v6)      ALLOW IN    Anywhere (v6)

On my laptop · what the internet reaches

you@laptop: ~
you@laptop:~$ bash probe.sh 159.89.17.196
PORT   TCP      WHAT IT IS
----   ---      ----------
22     OPEN     SSH
80     OPEN     HTTP (Traefik)
443    OPEN     HTTPS (Traefik)
6001   OPEN     realtime, live logs
6002   OPEN     realtime, web terminal
8000   OPEN     Coolify dashboard
8080   closed   coolify-proxy
5432   closed   Postgres
6379   closed   Redis

open to the internet: 6 of 12 probed

Ports 6001, 6002 and 8000 were never allowed in that firewall. All three answer anyway.

If ports 6001, 6002 or 8000 answered, keep reading. If only 22, 80 and 443 answered, someone already did this work on your box, and you can skip to the database section, which catches the case that bites later.

Why ufw does not stop Docker

Optional depth. The fix works even if you skip this: jump to putting the panel on a domain and start pasting. Stay if you want the one mechanism everything else on this page follows from.

Picture ufw as a doorman at your building's entrance, checking every visitor against the guest list you wrote. That works for traffic headed to the building itself. But when Docker publishes a container port, it does not argue with the doorman. It files a forwarding order at the post office instead: anything addressed to port 8000, deliver it straight to this container. The mail is rerouted before it ever reaches your door.

That forwarding order is a DNAT rule. It lives in the kernel's nat table, and the kernel reads that table first, before any of the filtering rules ufw manages. By the time your ufw rules would get a say, the packet has already been readdressed and accepted by Docker's own chains.

A chain here is just a list of firewall rules the kernel walks in order, a checklist a guard reads top to bottom. There is one chain that exists specifically so you can filter this traffic, called DOCKER-USER, and Docker never touches its contents. On a stock Coolify install it is empty: the page reserved for your rules is blank.

Where your ufw rules actually sit

Two journeys through the same machine. Only one of them meets your firewall.

Lane 1 · traffic to the host (SSH on port 22)

Internetpacket arrives on eth0
filter INPUTdestination is this machine
ufw-before-inputyour ufw rules run here
sshdallowed, because you allowed 22

Lane 2 · traffic to a container (8000, 6001, 6002)

Internetpacket arrives on eth0
nat PREROUTINGruns first, before any filter
DOCKER: DNATdport 8000 → 10.0.1.5:8080
filter FORWARDnow it is routed traffic

continues ↓

DOCKER-USERthe chain built for exactly this 229 packets · 0 rules
DOCKER-FORWARDaccepts the packet 229 packets
Your containerreached from the public internet
ufw-before-forward · ufw-after-forward · ufw-reject-forward Sit further down the same chain. The packet was already accepted above. 0 packets

229 packets reached Docker's chains. Zero reached ufw's. Packet counts from iptables -L FORWARD -n -v on the lab server.

You do not have to take the diagram's word for it, because iptables counts packets per rule. Here is the FORWARD chain on that server, with the counters:

iptables -L FORWARD -n -vnum   pkts bytes target
1      229 1321K DOCKER-USER
2      229 1321K DOCKER-FORWARD
3        0     0 ufw-before-logging-forward
4        0     0 ufw-before-forward
5        0     0 ufw-after-forward

229 packets reached Docker's chains. Zero reached ufw's. That is the whole story: on a Coolify box, ufw is not being overruled, it is never consulted. It still guards the host itself, which is why your SSH rules work exactly as you expect.

Coolify's own documentation is upfront about this. Its firewall page says that blocking ports with ufw alone "will not be effective" and points at the DOCKER-USER approach. What it does not do is show you what is currently open, or which ports you can close without losing the panel. That is the gap this guide fills.

Knowing which layer a rule actually lands on is most of self-hosting. It is why Self Hosting 2.0 builds the stack in order instead of handing you a checklist. A rule you cannot place in the path is a rule you are trusting, not using.

What Coolify publishes, port by port

Six ports, and they are not equally dangerous. This is the state of a stock 4.1.2 install with ufw allowing only 22, 80 and 443:

PortWhat it isReachable from the internet?
22SSH, on the host itselfYes, and it should be
80HTTP, the reverse proxy (one service that answers 80 and 443 and routes by domain)Yes, needed for certificates
443HTTPS, the reverse proxyYes, this is your traffic
8000The Coolify dashboardYes, even with a ufw deny rule
6001coolify-realtime, live deployment logsYes, never allowed in ufw
6002coolify-realtime, the in-browser terminalYes, never allowed in ufw
8080Published by the proxy containerNo, and the reason matters

Port 8000 is the one to sit with for a second. On my test box, ufw reported it restricted to 127.0.0.1. From my laptop, on a normal internet connection, it returned HTTP 200 and served the Coolify login page, 47,500 bytes of it. On a brand new install that page is the registration form, and whoever fills it in first owns the server.

The 8080 case, because it teaches the real rule

Optional depth. A small puzzle that teaches the rule worth remembering. In a hurry? Read the callout at the end of it, then keep moving.

Port 8080 looks identical to 6001 from the server's point of view. Both have a published port (a container port Docker opened on the server itself), both have a DNAT rule, neither has a ufw allow rule. One answers from the internet and one does not, and for a while I could not tell you why.

The answer is that 8080 is not protected. It is empty. The proxy container publishes 8080, and Coolify starts that proxy with its API dashboard switched off, so nothing inside the container is listening on 8080 to answer. The firewall path is wide open. There is just nobody home.

The rule worth remembering

Your attack surface is the list of published ports, not the list of ports that answer today. A port that says "connection refused" is already open at the firewall. It is waiting for something to listen on it. Flip one setting, or deploy one container that binds that port, and it is public with no firewall change and no warning.

Put the panel on a domain first

This step is out of order in most people's heads, including mine when I started. It has to come before the firewall work, and here is why.

Right now your browser talks to port 6001 for live logs and 6002 for the terminal. Close those first and you will "successfully" firewall your server and then wonder why deployment logs stopped streaming. Give the instance a domain first and Coolify moves both of those onto 443 for you.

In your DNS provider, create an A record, the entry that maps a name to an IP address, and point it at your server's IP. Give it a few minutes to take effect. Then, in the Coolify dashboard, open Settings, put the full URL into the Instance Domain field, and save:

https://coolify.yourdomain.com

Coolify writes a proxy configuration file the moment you save. You do not have to edit it, but it is worth seeing, because it is the thing that makes the next section safe. Run this on the server as root to read it:

cat /data/coolify/proxy/dynamic/coolify.yaml

The interesting part is the two routers Coolify generated for you:

/data/coolify/proxy/dynamic/coolify.yamlcoolify-realtime-wss:
  entryPoints:
    - https
  service: coolify-realtime
  rule: 'Host(`coolify.yourdomain.com`) && PathPrefix(`/app`)'
coolify-terminal-ws:
  entryPoints:
    - https
  service: coolify-terminal
  rule: 'Host(`coolify.yourdomain.com`) && PathPrefix(`/terminal/ws`)'

Live logs now arrive on /app and the terminal on /terminal/ws, both over HTTPS on 443. Ports 6001 and 6002 have nothing left to do from the outside world.

Load the dashboard on the new domain and check the padlock before you continue. If HTTPS is not working yet, fix that first: closing port 8000 while the domain is broken leaves you with no way into the panel at all.

New to this stack?

If you do not have Coolify running yet, start with my Coolify install guide and come back here afterwards. The Self Hosting 2.0 course takes the same path from an empty VPS to a stack you actually run, in order, with nothing skipped.

The fix: five rules ufw will remember

Two parts. The ufw part you already know, and the part ufw cannot do on its own.

Part 1: the host firewall

Run these on the server as root, in this order. Port 22 is allowed before the firewall comes up, so your session cannot be cut. Each command prints a short confirmation:

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Use allow on port 22, never limit

A lot of hardening advice suggests ufw limit 22/tcp. On a Coolify box that quietly breaks the panel: Coolify opens SSH connections to your server constantly, trips the rate limit, and starts reporting the server as unreachable at random. I measured it on a previous run, 5 successful connections out of 20 with the limit rule and 20 out of 20 without. The full breakdown is in the guide on Coolify saying "server is not reachable". Use allow, and put fail2ban on failed logins instead.

Part 2: the part ufw cannot do

These rules go in the DOCKER-USER chain. You do not add them with the ufw command, because ufw has no syntax for this chain. You append them to a file that ufw reads every time it loads, which is what makes them survive a reboot.

Before editing, take a backup. Run this on the server as root:

cp /etc/ufw/after.rules /etc/ufw/after.rules.bak

Now add this block to the end of that file, after the existing COMMIT line. The block assumes your public interface is eth0. If yours has another name (ip -4 route show default prints it), put it in the personalize card at the top and the block updates:

/etc/ufw/after.rules
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER ! -i eth0 -j RETURN
-A DOCKER-USER -i eth0 -p tcp -m multiport --dports 80,443 -j RETURN
-A DOCKER-USER -i eth0 -p udp -m multiport --dports 443 -j RETURN
-A DOCKER-USER -i eth0 -j DROP
COMMIT

Read top to bottom, that says: let through replies that belong to conversations already in progress, ignore anything that did not come in through the public interface so containers can still talk to each other, allow the public web on 80 and 443, and drop everything else. The last line is the one that matters most, because it is a default deny. Ports you have not thought of yet are already covered.

Apply it. Run this on the server as root, and it prints Firewall reloaded:

ufw reload

Confirm the rules are live. This prints the five rules with their packet counters:

iptables -L DOCKER-USER -n -v --line-numbers
Do not install iptables-persistent for this

It is the usual answer for making iptables rules survive a reboot, and on Ubuntu 24.04 it removes ufw. The two packages conflict, and with apt -y there is no prompt and no warning in the output. I hit this mid-test and only found it in /var/log/apt/history.log afterwards:

/var/log/apt/history.logCommandline: apt-get install -y iptables-persistent
Install: iptables-persistent:amd64 (1.0.20), netfilter-persistent:amd64 (1.0.20, automatic)
Remove: ufw:amd64 (0.36.2-6)

The after.rules file above needs nothing installed, and ufw reapplies it at every boot. That is the whole reason to put the rules there.

Verify from outside

Same check as the first section, same script, from your laptop. This is not optional: the server cannot tell you whether this worked.

The same probe, before and after

Same script, same server, same seven ports. One thing changed between them.

Before · ufw active, three ports allowed

you@laptop: ~
PORT   TCP      WHAT IT IS
----   ---      ----------
22     OPEN     SSH
80     OPEN     HTTP (Traefik)
443    OPEN     HTTPS (Traefik)
6001   OPEN     realtime, live logs
6002   OPEN     realtime, web terminal
8000   OPEN     Coolify dashboard
8080   closed   coolify-proxy

open to the internet: 6 of 7

After · five rules in DOCKER-USER

you@laptop: ~
PORT   TCP      WHAT IT IS
----   ---      ----------
22     OPEN     SSH
80     OPEN     HTTP (Traefik)
443    OPEN     HTTPS (Traefik)
6001   closed   realtime, live logs
6002   closed   realtime, web terminal
8000   closed   Coolify dashboard
8080   closed   coolify-proxy

open to the internet: 3 of 7

The dashboard, the live logs and the web terminal all kept working. The next section is the proof.

Six of seven down to three of seven. The dashboard on YOUR.SERVER.IP:8000 now refuses to connect at all, while the same dashboard on your domain keeps answering normally.

Proof that Coolify still works

This is the part I care about most, because a firewall that silently breaks your panel is worse than no firewall. You would not find out until the next time you shipped.

Four things to check, in the order they are likely to break.

The dashboard. Load it on your domain. It should be indistinguishable from before, because nothing about it changed except the door you came in through.

Live logs. Open any resource and watch the log panel stream. Behind the scenes the browser now opens a websocket, a connection that stays open so the server can stream updates, to wss://yourdomain/app/... instead of hitting port 6001. On my run it was the only realtime connection the page made, and it connected on 443.

The web terminal. This is the one people assume they have lost. Open Terminal in the sidebar, pick your server, click Connect:

The Coolify web terminal showing a live root shell prompt on the server, with port 6002 closed to the internet.
A live root shell in the browser, with port 6002 unreachable from the internet. The terminal rides /terminal/ws on 443.

A real deployment. Deploy something. On the locked-down box I deployed an nginx image to a fresh subdomain and it came up normally, serving HTTP 200 over HTTPS with a new certificate. My run reached running 24 seconds after I hit deploy, though that number is mostly how fast the image pulled, so treat it as "it did not hang", not as a benchmark.

One detail from that deployment is worth its own line. The container Coolify built publishes no host port at all. The proxy reaches it over the internal Docker network. A properly routed app never needed a published port, which is why a default-deny policy costs you nothing.

The database you deploy tomorrow

Everything so far was about Coolify's own ports. This section is about yours, and it is the one that actually leaks data.

The good news first: a database you create in Coolify is not exposed by default. I deployed a Postgres with default settings and its port stayed inside the Docker network. Nothing on the host was listening, and no DNAT rule appeared.

Then I ticked the option to make it publicly available, which people do for a reason: connecting a local client, a migration, a reporting tool. Coolify starts a small proxy container that publishes the port on 0.0.0.0, and a DNAT rule appears. Throughout all of this, ufw had zero rules mentioning port 5432. The only thing standing in front of that database is the chain you just configured.

So I tested exactly that, changing one variable and nothing else:

DOCKER-USER: 5 rules, the policy above 5432 closed
DOCKER-USER: flushed to 0 rules, nothing else changed 5432 OPEN
DOCKER-USER: restored with ufw reload 5432 closed

The firewall was written before that database existed and it covered it anyway. That is the argument for a default deny over a list of blocked ports: the rule you write today has to protect the thing you deploy in three months, when you have forgotten this page exists.

Does it survive a reboot?

A firewall rule that dies at the next reboot is worse than no rule, because you stop checking. So I tried to knock these off the box three ways:

  • Restarting a container that publishes ports. Docker rewrites its NAT rules. The five DOCKER-USER rules survived.
  • Restarting the Docker daemon with systemctl restart docker, which rebuilds every chain Docker owns. Still five rules, and Docker's own DNAT rules came back alongside them.
  • A full reboot. Five rules, with live packet counters, and all six Coolify containers healthy.

After the reboot the probe from my laptop returned the same three open ports, and the dashboard answered on the domain. That is what /etc/ufw/after.rules buys you: ufw reapplies the file on every boot, so the policy is reinstalled before anything can use the gap.

SSH, without breaking the panel

Firewalling the ports is half the job. The other half is SSH, and it has a trap in it: Coolify manages your server over SSH as root, so the standard hardening advice can cut the panel's only line to the box.

The short version, all of it measured on a previous run:

  • Use PermitRootLogin prohibit-password, not no. Root keeps key login, loses password login. no breaks Coolify immediately.
  • Set PasswordAuthentication no. This is the setting that actually stops brute-force logins.
  • If you want 2FA on SSH, scope it to humans with a Match User root block that keeps root on key-only. Applied globally, it breaks Coolify, because a machine cannot type a code from your phone.
  • Put those lines in a drop-in file (a small config file the system reads alongside the main one) at /etc/ssh/sshd_config.d/99-coolify-safe.conf. On Ubuntu 24.04 edits to the main file are often overridden by drop-ins that load first.
  • Install fail2ban and let it ban on failed authentication. That is the right tool for brute-force, not a firewall rate limit.

I broke a live server five separate ways to work those out, with the exact error string each one produces. If SSH is where your problem actually is, the "server is not reachable" guide linked above is the page you want.

One thing worth knowing about non-root users

Coolify 4.1.2 does support running against a non-root user. But that user has to be in the docker group, and docker group membership is root-equivalent: in a test, a normal uid-1000 user in that group read /etc/shadow through a container in one command. It is a supported setup. It just buys less security than it looks like.

Your Coolify account

The panel is the control plane for every app, database and credential on the server. Three things, quickly, all checked against 4.1.2.

Claim the admin account the moment you install. A fresh Coolify serves a registration page to whoever reaches it first, and the first account created owns the server. My test box sat on a public IP with that page reachable for just under six minutes before I claimed it, and it was still unclaimed when I got there. On a different lab server I left exposed on purpose, the first uninvited SSH login attempt arrived 2 minutes and 35 seconds after boot. Nobody knew either machine existed. Bots scan the whole address space continuously.

Registration closes itself. Once the first account exists, the instance turns registration off. Worth confirming rather than assuming, and it is under Settings.

Turn on two-factor authentication. Coolify has it natively. Open Profile, find the Two-factor Authentication section, click Configure:

The Coolify Profile page on version 4.1.2 showing a Two-factor Authentication section with a Configure button.
Coolify 4.1.2, Profile page. Two-factor authentication is built in.
Treat an API token like the password

A Coolify API token with root permissions returns your deployed database credentials in plain text. I asked the API for the databases on my test box and got back the Postgres password and a full connection string with the password embedded in it. That is not a bug, it is what the endpoint is for. It does mean a token pasted into a script, a CI job or a chat window is worth exactly as much as your dashboard login.

Security updates you do not have to run

Unattended upgrades are in every checklist and almost never verified. Install and enable them. Run this on the server as root:

apt install -y unattended-upgrades

Then tell it to run daily. This creates the config file with both settings enabled:

/etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

Now force one run so you can see it actually do something, rather than trusting that it will:

unattended-upgrade -v

On my box that first run patched 65 packages, including openssh-server, libc6, perl and libpam-modules. It took about five minutes and finished with All upgrades installed. My SSH session stayed up through its own openssh-server upgrade.

Leave automatic reboots switched off, which is the Ubuntu default. On a Coolify box an unannounced reboot takes every app you host down with it. Reboot on your own schedule, when a kernel update needs it.

What this guide does not cover

Being clear about the edges, so you know what you still have to think about:

  • IPv6. The rules above are IPv4. If your server has a public IPv6 address, it needs the same treatment in after6.rules. That is a real gap, and I have not measured it.
  • General Linux hardening. No CIS benchmarks, no SELinux or AppArmor, no kernel tuning. Useful, different topic.
  • Coolify Cloud. Self-hosted only. Cloud needs 22, 80 and 443 and has none of this problem.
  • Putting the panel behind a VPN. A good option, especially for a team. Tailscale or WireGuard in front of the dashboard is a different guide.
  • Your provider's firewall. Most hosts offer one that filters before traffic reaches your server, which sidesteps the Docker problem entirely. It also means your firewall lives in a web console instead of in a file you can read, back up and version. I would rather have both.

Questions people actually ask

Why is my dashboard still reachable on port 8000 after ufw deny 8000?

Because Docker published that port, and publishing writes a DNAT rule the kernel reads before ufw's filtering. On my test server ufw reported port 8000 restricted to localhost while my laptop still got HTTP 200 and the login page. The fix is a rule in DOCKER-USER, not another ufw rule.

Does ufw protect a Coolify server at all?

Yes, it protects the host. SSH terminates on the machine itself and travels the INPUT chain, where your ufw rules apply normally. What it does not filter is traffic bound for a container, which is every port Docker published.

Which Coolify ports can I safely close?

8000, 6001 and 6002, once the panel is on a domain with HTTPS. Setting the instance domain makes Coolify route live logs and the browser terminal over 443. Close them first and you lose both.

Do I need ufw-docker, or is DOCKER-USER enough?

DOCKER-USER is enough here, and it needs nothing installed. Five rules in after.rules survived a container restart, a daemon restart and a reboot on my test box. ufw-docker is a good tool if you want per-container rules in ufw syntax, but for a policy this small it is a dependency you do not need.

Will this break my deployments?

No. A fresh application deployed on the locked-down box reached running in 24 seconds and served HTTPS on a new domain. Apps routed through Coolify's proxy do not publish a host port at all.

My server has no domain yet. Can I still do this?

Do the domain first. Without it the dashboard only exists on IP:8000, and this policy closes that. If you cannot use a domain yet, add a temporary rule allowing 8000 from your own IP address in the DOCKER-USER block, and remove it once the domain is live.

I locked myself out of the dashboard. Now what?

SSH is untouched by these rules, so log in and run ufw disable, which drops the DOCKER-USER policy with it. You are back to the exposed state, which is at least a working state. Then fix the domain and re-enable. If SSH itself is the problem, that is the "server is not reachable" guide instead.

Tested on: Coolify 4.1.2 · Ubuntu 24.04 · Docker 29.6.2 · DigitalOcean droplet, 2 vCPU / 4 GB, Frankfurt.
Lab run: 2026-07-29. Last verified: 2026-07-29. Every port result, packet count and timing on this page came off that server, probed from a laptop on a normal internet connection.

Want the whole path, in order?

This guide is one piece of it. In Self Hosting 2.0 I take an empty VPS all the way to a stack you run yourself: Coolify, real apps, backups, email, and the security work above in the order it should actually happen. 34 lessons, and nothing skipped between them.

Hasan Aboul Hasan giving a thumbs up

Keep it at 22, 80 and 443.

Hasan Aboul Hasan builds open-source tools and teaches solo developers how to build, host, and sell AI-powered products. Founder of LearnWithHasan.com, creator of SimplerLLM and PyRunner.

Vibe Engineering Blocks — free guide
Free guide

Get the free Vibe Engineering Blocks guide

The exact building blocks I use to ship real products with AI — yours as a free PDF.

Free PDF · double opt-in · unsubscribe anytime.

Have a question? Ask it in the community — it's tagged #guide and linked back here. Reading is open to everyone; posting needs a free account.

Loading questions…