tailscale0, then install Coolify on the tailnet. Funnel a single app port if you need the public internet. Authenticate with Auth.js (formerly NextAuth.js). Do not treat last year's VPS price as current.How do you protect self-hosted Coolify apps?
Put the Coolify host on a Tailscale tailnet and deny all other inbound traffic with UFW. Install Coolify only after the host is reachable solely over Tailscale. Expose a single app to the public internet later, on purpose, with Tailscale Funnel or a reverse proxy you control.
Coolify is a self-hosted PaaS: it builds and runs containers, databases, and the proxy on a VM you own. That is more control than a managed host, and it is more of your attack surface. The dashboard, Docker socket, and SSH are high-value targets. Do not leave them on a public IP while you "finish setup."
Host pricing changes. Size the VM to Coolify's current CPU and memory requirements instead of assuming a fixed monthly figure.
For image hardening see secure container deployments. For a different self-host shape see secure Fly.io apps.
What is the Coolify hardening checklist?
- Install Tailscale on the VM first and enable Tailscale SSH.
- Enable UFW: default deny inbound, allow outbound, allow
tailscale0. - Confirm the public IP no longer accepts SSH or HTTP.
- Install Coolify over the tailnet.
- Permit only the Docker bridge subnets Coolify needs.
- Open the dashboard at
http://<tailscale-ip>:8000, not the public IP. - Map application ports only when you intend private or public access.
- Authenticate the app with Auth.js (formerly NextAuth.js) or another maintained library.
- Screen public routes in application code.
How do you lock Coolify behind Tailscale?
SSH in on the provider's first-login path once. Install Tailscale immediately:
curl -fsSL https://tailscale.com/install.sh | shsudo tailscale up --sshtailscale ip--ssh delegates SSH authentication to your Tailscale identity provider. After the node is up, connect with ssh user@<tailscale-ip> from a device on the tailnet. Disable password SSH on the public interface once Tailscale SSH works.
Then lock the host:
sudo ufw enablesudo ufw default deny incomingsudo ufw default allow outgoingsudo ufw allow in on tailscale0sudo ufw reloadsudo ufw status lists inbound allows only on tailscale0. Probe the public IP from a network that is not on the tailnet; SSH and port 8000 should fail.
How do you install Coolify on a private host?
Use Coolify's current install script over the tailnet SSH session. The script installs Docker and the Coolify stack. Do not run it on a host that is still open to the internet.
After install, Docker bridge networks must be able to reach the host. UFW will otherwise break Coolify's own containers. List networks (docker network ls) and inspect bridge and coolify for their subnets (often 172.17.0.0/16 and 172.18.0.0/16). Allow those subnets to the Docker gateway you actually have:
sudo docker network inspect bridge --format '{{(index .IPAM.Config 0).Subnet}}'sudo docker network inspect coolify --format '{{(index .IPAM.Config 0).Subnet}}'# example: replace with the subnets you inspectedsudo ufw allow from 172.17.0.0/16 to 172.17.0.1sudo ufw allow from 172.18.0.0/16 to 172.17.0.1Open http://<tailscale-ip>:8000 and complete onboarding. If the wizard cannot add localhost, add a server with host.docker.internal.
How do you deploy an app that stays private?
Create a Coolify project and a resource from your Git repository. For a Next.js app, expose container port 3000. If the app should stay on the tailnet only, map 3000:3000 and do not attach a public wildcard domain that points at the VM's public IP.
Set environment variables in Coolify's UI or, better, inject them from a secrets manager. Put ARCJET_KEY there. Do not commit it.
After deploy, open http://<tailscale-ip>:3000 from a tailnet device. That is the private baseline.
How do you expose one Coolify app publicly?
Tailscale Funnel publishes a single port on a Tailscale HTTPS hostname without opening the rest of the VM:
sudo tailscale funnel 3000You may need to enable Funnel on the tailnet first. Only the process bound to that port becomes public. The Coolify dashboard on 8000 stays private if you did not funnel it.
Alternatively, terminate TLS on a public reverse proxy and proxy to the container. That is a larger surface: you must keep the proxy patched and you must not accidentally publish the dashboard. Funnel is the smaller default.
Do not funnel port 8000. Do not point a public DNS record at the Coolify UI. If you need a custom domain for the app, attach it only to the application resource, and keep the dashboard on the tailnet hostname.
Coolify will also run databases you attach as resources. Those ports stay on Docker networks. Do not add a port mapping for Postgres or Redis unless you have a separate, authenticated reason. Back up volumes on a schedule you have tested; a private network does not replace backups.
What application controls still matter?
A private host does not protect a public Funnel URL. Validate input. Authenticate with Auth.js (the NextAuth.js rename is done; use current Auth.js packages and docs). Authorize on the server. Keep dependencies current.
Screen public handlers before they do expensive work. @arcjet/next v1 in a Next.js Route Handler:
import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";import { NextResponse } from "next/server";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: ["CATEGORY:SEARCH_ENGINE"] }), slidingWindow({ mode: "LIVE", interval: 60, max: 60 }), ],});
export async function POST(req: Request) { const decision = await aj.protect(req); if (decision.isDenied()) { if (decision.reason.isRateLimit()) { return NextResponse.json({ error: "Too many requests" }, { status: 429 }); } return NextResponse.json({ error: "Forbidden" }, { status: 403 }); }}If Coolify's proxy is the only hop, configure trusted proxies on the SDK so rate limits key on the client, not the proxy.
Why Tailscale plus UFW instead of a public Coolify install?
Coolify's proxy and Docker API are convenient and they are powerful. A remote code execution bug in a published container, or a dashboard left on a default port, is a full-host problem if the VM is on the internet.
Tailscale plus default-deny UFW gives you:
- No public SSH or dashboard during install
- Deliberate public exposure of one port
- Access from any device on the tailnet without opening more firewall holes
Pair that with non-root containers and secret injection from secure container deployments. If you later move the same app to Fly.io, the application rules stay; only the network story changes (secure Fly.io apps).
Frequently asked questions
How do you protect a Coolify server?
Put the host on Tailscale, enable Tailscale SSH, and use UFW to deny all inbound traffic except the tailscale0 interface. Install Coolify only after the public IP no longer accepts SSH or HTTP.
Should the Coolify dashboard be public?
No. Open it at http://<tailscale-ip>:8000. Do not funnel port 8000 or point public DNS at the UI.
How do you publish one Coolify app?
tailscale funnel on the app port, or a reverse proxy you patch yourself. Only that process should be public.
Is NextAuth still the right name?
No. The project is Auth.js. Use current Auth.js packages and docs.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.