<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged hostinger</title>
<link>https://coderlegion.com/tag/hostinger</link>
<description>Powered by Question2Answer</description>
<item>
<title>Setting Up Directus on a Coolify VPS (And Fixing the &quot;Unhealthy&quot; Error)</title>
<link>https://coderlegion.com/23995/setting-up-directus-on-a-coolify-vps-and-fixing-the-unhealthy-error</link>
<description>&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Directus is a backend for developers. It can connect to any SQL database and asset storage, and it provides developer tooling — which the Directus team calls the &quot;Data Engine&quot; — alongside a web application that lets both developers and non-developers manipulate data and assets through a no-code interface called the &quot;Data Studio.&quot;&lt;/p&gt;
&lt;p&gt;Directus also offers granular access control, meaning end users can only see, manipulate, and interact with the data allowed by their role and access policy — enforced consistently by both the engine and the studio. It's extensible through its own marketplace, and it's completely free, with a free license available.&lt;/p&gt;
&lt;p&gt;Directus is also known as a &lt;strong&gt;headless CMS&lt;/strong&gt; — meaning it doesn't ship with a front end attached out of the box, the way WordPress traditionally did. You can pair it with whichever front end you prefer; TanStack Start is my front end of choice.&lt;/p&gt;
&lt;p&gt;In this post, I'll self-host Directus on my Coolify-managed VPS, walk through both setup methods, and — since nothing ever works perfectly on the first try — fix the actual errors I hit along the way.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;📌 &lt;strong&gt;New here?&lt;/strong&gt; I'd recommend checking out my earlier posts where I set up the VPS itself and installed Coolify on it, before continuing with this one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;let-s-get-started&quot;&gt;Let's Get Started&lt;/h3&gt;
&lt;p&gt;There are two ways to set up Directus on a Coolify-managed VPS. I'll cover the first one here.&lt;/p&gt;
&lt;h4 id=&quot;1-log-in-to-your-coolify-dashboard&quot;&gt;1. Log in to your Coolify Dashboard&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;In the sidebar menu, go to &lt;strong&gt;Projects → +Add&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Give your project a name and description, and press continue&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-add-a-resource&quot;&gt;2. Add a Resource&lt;/h4&gt;
&lt;p&gt;After naming your project, you should be redirected to the project's page, where you can add a resource. If that redirect doesn't happen, click &lt;strong&gt;Projects&lt;/strong&gt; in the sidebar, then click into your project, and you should see &lt;strong&gt;+Add Resource&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click &lt;strong&gt;+Add Resource&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Under Applications, select &lt;strong&gt;Docker Compose Empty&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A blank Docker Compose file will open&lt;/li&gt;
&lt;li&gt;Go to &lt;a rel=&quot;nofollow&quot; href=&quot;https://directus.com&quot;&gt;directus.com&lt;/a&gt; → &lt;strong&gt;Docs&lt;/strong&gt; → &lt;strong&gt;Hosting&lt;/strong&gt; → &lt;strong&gt;Deployment&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Scroll down until you find the example Docker Compose YAML file, and copy it&lt;/li&gt;
&lt;li&gt;Paste it into your blank Coolify Compose file&lt;/li&gt;
&lt;li&gt;Find the &lt;code&gt;directus:&lt;/code&gt; service, and under it, &lt;code&gt;image:&lt;/code&gt; — update this to the version you want. As of this post, the latest version is &lt;code&gt;12.2.0&lt;/code&gt;:&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;image: directus/directus:12.2.0
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Scroll to the end and set your own admin email and password — these become your Data Studio login credentials&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt; at the top&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-configure-the-service&quot;&gt;3. Configure the Service&lt;/h4&gt;
&lt;p&gt;You should now be on the &lt;strong&gt;Configuration&lt;/strong&gt; screen, with a &lt;strong&gt;Deploy&lt;/strong&gt; button (yellow outline arrow) in the top right.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Under &lt;strong&gt;Network&lt;/strong&gt;, check the box&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Services&lt;/strong&gt;, click &lt;strong&gt;Settings&lt;/strong&gt; on the &lt;code&gt;directus&lt;/code&gt; tab&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;Domain&lt;/strong&gt; field, enter your subdomain — e.g. &lt;code&gt;https://directus.yourdomain.com&lt;/code&gt;. &lt;strong&gt;Use &lt;code&gt;https&lt;/code&gt;&lt;/strong&gt;, so Traefik knows to issue a Let's Encrypt certificate for it&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;, then &lt;strong&gt;Back&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Edit Compose File&lt;/strong&gt; at the top, scroll down to &lt;code&gt;PUBLIC_URL&lt;/code&gt;, and set it to match your subdomain:&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;PUBLIC_URL: https://directus.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;, close the modal&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Deploy&lt;/strong&gt;, then &lt;strong&gt;Confirm&lt;/strong&gt; on the popup&lt;/li&gt;
&lt;li&gt;Wait for the deployment to finish&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;4-launch-directus&quot;&gt;4. Launch Directus&lt;/h4&gt;
&lt;p&gt;On the Configuration screen, click &lt;strong&gt;Links&lt;/strong&gt;, then click your new subdomain.&lt;/p&gt;
&lt;h3 id=&quot;the-errors&quot;&gt;The Errors&lt;/h3&gt;
&lt;p&gt;At this point, your browser will show two errors: a &lt;strong&gt;&quot;Not Secure&quot;&lt;/strong&gt; warning in the address bar, and a &lt;strong&gt;&quot;no available server&quot;&lt;/strong&gt; page.&lt;/p&gt;
&lt;p&gt;Back on the Coolify dashboard, you'll notice the Directus instance is showing as &lt;strong&gt;unhealthy&lt;/strong&gt;. Traefik will not route traffic to a service that's failing its health check — which is exactly what's happening here. So the first job is figuring out &lt;em&gt;why&lt;/em&gt; the health check is failing.&lt;/p&gt;
&lt;h3 id=&quot;diagnosing-the-problem&quot;&gt;Diagnosing the Problem&lt;/h3&gt;
&lt;p&gt;SSH into your VPS to start digging.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Check your running containers:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Install &lt;code&gt;jq&lt;/code&gt;&lt;/strong&gt;, so the JSON output we're about to read is actually readable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install jq
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Check the health check failure log&lt;/strong&gt; (grab your Directus container's name from the &lt;code&gt;docker ps&lt;/code&gt; output above):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker inspect --format='{{json .State.Health}}' &amp;lt;your-directus-container-name&amp;gt; | jq
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;Status&quot;: &quot;unhealthy&quot;,
  &quot;FailingStreak&quot;: 79,
  &quot;Log&quot;: [
    {
      &quot;Start&quot;: &quot;2026-07-30T18:12:21.445964902-04:00&quot;,
      &quot;End&quot;: &quot;2026-07-30T18:12:21.502447668-04:00&quot;,
      &quot;ExitCode&quot;: 1,
      &quot;Output&quot;: &quot;wget: can't connect to remote host: Connection refused\n&quot;
    },
    {
      &quot;Start&quot;: &quot;2026-07-30T18:12:31.505411263-04:00&quot;,
      &quot;End&quot;: &quot;2026-07-30T18:12:31.556307544-04:00&quot;,
      &quot;ExitCode&quot;: 1,
      &quot;Output&quot;: &quot;wget: can't connect to remote host: Connection refused\n&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first clue: &lt;code&gt;&quot;wget: can't connect to remote host: Connection refused&quot;&lt;/code&gt;. Whatever endpoint the health check is trying to reach, it's being actively refused — not timing out, refused. That distinction matters, and it's the thread that leads to the actual fix.&lt;/p&gt;
&lt;h3 id=&quot;inspecting-the-docker-compose-file&quot;&gt;Inspecting the Docker Compose File&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;From the Coolify dashboard, select &lt;strong&gt;Projects&lt;/strong&gt; in the sidebar&lt;/li&gt;
&lt;li&gt;Click your project&lt;/li&gt;
&lt;li&gt;Click the service, then &lt;strong&gt;Edit Compose File&lt;/strong&gt; next to &lt;strong&gt;Service Stack&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here's the relevant section:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;directus:
  image: 'directus/directus:12.2.0'
  ports:
    - '8055:8055'
  volumes:
    - './uploads:/directus/uploads'
    - './extensions:/directus/extensions'
  depends_on:
    database:
      condition: service_healthy
    cache:
      condition: service_healthy
  healthcheck:
    test:
      - CMD-SHELL
      - 'wget --spider -q http://localhost:8055/server/ping || exit 1'
    interval: 10s
    timeout: 5s
    retries: 5
    start_interval: 5s
    start_period: 30s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem is in the &lt;code&gt;wget&lt;/code&gt; line — specifically, &lt;code&gt;http://localhost:8055&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;why-localhost-breaks-this&quot;&gt;Why &lt;code&gt;localhost&lt;/code&gt; Breaks This&lt;/h4&gt;
&lt;p&gt;Inside a Linux container, &lt;code&gt;localhost&lt;/code&gt; isn't an address — it's a hostname that has to be resolved first, and it typically maps to &lt;strong&gt;two&lt;/strong&gt; addresses at once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;127.0.0.1   localhost   # IPv4
::1         localhost   # IPv6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When &lt;code&gt;wget&lt;/code&gt; looks up &lt;code&gt;localhost&lt;/code&gt;, the system's resolver hands back both addresses, and &lt;code&gt;wget&lt;/code&gt; tries them in whatever order it receives them — which, on many Linux/Alpine base images, tends to prefer &lt;strong&gt;IPv6 first&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Checking the container's logs confirms what's actually listening:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker logs &amp;lt;your-directus-container-name&amp;gt; --tail 150
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Directus only bound to IPv4. The startup log says exactly that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Server started at http://0.0.0.0:8055
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;0.0.0.0&lt;/code&gt; means &quot;listen on every IPv4 interface&quot; — Directus never opened an IPv6 socket at all. So when &lt;code&gt;wget&lt;/code&gt; tries &lt;code&gt;::1:8055&lt;/code&gt; first, there's genuinely nothing listening there, and the OS doesn't wait around wondering — it immediately sends back a rejection (a TCP RST). That's exactly why the log shows an instant &lt;strong&gt;&quot;Connection refused&quot;&lt;/strong&gt; rather than a slow timeout. A timeout would mean something was reachable but not responding; a refusal means the OS said &quot;nothing's here&quot; right away.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;127.0.0.1&lt;/code&gt; sidesteps the whole problem, because it's already a literal IP address — no hostname lookup, no ambiguity about which protocol family to try, no chance of picking the wrong one. It goes straight to the one address where Directus is actually listening.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The Fix&lt;/h3&gt;
&lt;p&gt;Change &lt;code&gt;localhost&lt;/code&gt; to &lt;code&gt;127.0.0.1&lt;/code&gt; in the health check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;directus:
  image: 'directus/directus:12.2.0'
  ports:
    - '8055:8055'
  volumes:
    - './uploads:/directus/uploads'
    - './extensions:/directus/extensions'
  depends_on:
    database:
      condition: service_healthy
    cache:
      condition: service_healthy
  healthcheck:
    test:
      - CMD-SHELL
      - 'wget --spider -q http://127.0.0.1:8055/server/ping || exit 1'
    interval: 10s
    timeout: 5s
    retries: 5
    start_interval: 5s
    start_period: 30s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save the change, close the modal, and click &lt;strong&gt;Restart&lt;/strong&gt; in the top right. Wait for the restart to complete — it may look like nothing's happening for a bit, so give it a moment. Once it's finished, close the logs modal, and Directus should show as &lt;strong&gt;healthy&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Click &lt;strong&gt;Links&lt;/strong&gt;, then your Directus subdomain, and you should land on the Data Studio login screen.&lt;/p&gt;
&lt;h3 id=&quot;logging-in-amp-licensing&quot;&gt;Logging In &amp;amp; Licensing&lt;/h3&gt;
&lt;p&gt;Enter the credentials you set earlier in the Compose file. You'll be asked whether you have a license or want to install the Core — choose &lt;strong&gt;Core&lt;/strong&gt; for now, and follow the flow to complete sign-up.&lt;/p&gt;
&lt;p&gt;At this stage, your instance isn't fully unlocked — you'll need a license. Good news: it's completely free if your business's revenue is under $5M and your team is under 50 people.&lt;/p&gt;
&lt;p&gt;Head to &lt;a rel=&quot;nofollow&quot; href=&quot;https://directus.com/oig&quot;&gt;directus.com/oig&lt;/a&gt; to apply for your key. Once you have it, go to your Directus Studio, click the gear icon (Settings) in the sidebar, click &lt;strong&gt;License&lt;/strong&gt;, and add your key there to unlock all features. Your key is valid for a year.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Got questions, or hit a different error setting this up? Drop a comment below — happy to help troubleshoot.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23995/setting-up-directus-on-a-coolify-vps-and-fixing-the-unhealthy-error</guid>
<pubDate>Sun, 02 Aug 2026 14:42:21 +0000</pubDate>
</item>
<item>
<title>Coolify: The Complete Manual Setup Guide (For When the Auto-Install Script Won't Cut It)</title>
<link>https://coderlegion.com/23307/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-wont-cut-it</link>
<description>&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=jlUzYm6W-bI&quot;&gt;&lt;img src=&quot;https://img.youtube.com/vi/VIDEO_ID_HERE/0.jpg&quot; alt=&quot;Watch the video&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Coolify's one-line install script is great — until it isn't. Right now it officially supports Ubuntu 20.04, 22.04, and 24.04 LTS. If you're running anything newer (Ubuntu's already on 26.04 LTS), the script won't work and you're left doing it manually.&lt;/p&gt;
&lt;p&gt;This is that manual walkthrough — set up in the order that fits a security-first VPS workflow rather than the order Coolify's own docs use. If you've been following along with the Ansible playbooks from earlier in this series, this picks up right where that left off.&lt;/p&gt;
&lt;h3 id=&quot;minimum-hardware-requirements&quot;&gt;Minimum Hardware Requirements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPU:&lt;/strong&gt; 2 cores&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory:&lt;/strong&gt; 2 GB RAM&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage:&lt;/strong&gt; 30 GB free&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Coolify can technically run below this, but it's not recommended.&lt;/p&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before touching Coolify itself, you'll need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SSH access to your VPS&lt;/li&gt;
&lt;li&gt;CURL installed&lt;/li&gt;
&lt;li&gt;Docker Engine installed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you're reconnecting to a server you've rebuilt or re-provisioned, clear the old fingerprint first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh-keygen -f '/home/your-path/.ssh/known_hosts' -R 'your-vps-ip'
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;installing-ssh&quot;&gt;Installing SSH&lt;/h4&gt;
&lt;p&gt;If you followed the earlier videos in this series, OpenSSH is already installed. If not:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install -y openssh-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confirm it's running and check which port it's listening on (you should have already changed this from the default 22 — see the VPS security video):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status ssh
sudo ss -tulpn | grep ssh
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;installing-curl&quot;&gt;Installing CURL&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install -y curl
curl --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;ca-certificates&lt;/code&gt; also get installed as part of the &lt;code&gt;apt-update&lt;/code&gt; Ansible playbook below, so this may already be handled.&lt;/p&gt;
&lt;h3 id=&quot;running-the-first-ansible-playbook&quot;&gt;Running the First Ansible Playbook&lt;/h3&gt;
&lt;p&gt;Connect Ansible to the VPS:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ANSIBLE_HOST_KEY_CHECKING=FALSE ansible -i ./inventory/hosts vpsDemo -m ping --user root --ask-pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then run the update playbook:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ansible-playbook ./playbooks/apt-update.yml --user root -e &quot;ansible_port=22&quot; --ask-pass --ask-become-pass -i ./inventory/hosts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you haven't set up the Ansible inventory and playbooks from the earlier videos, do that first — this guide assumes they're already in place.&lt;/p&gt;
&lt;h3 id=&quot;installing-docker-engine&quot;&gt;Installing Docker Engine&lt;/h3&gt;
&lt;p&gt;Remove any conflicting packages first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add Docker's official GPG key and repo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources &amp;lt;&amp;lt;EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release &amp;amp;&amp;amp; echo &quot;${UBUNTU_CODENAME:-$VERSION_CODENAME}&quot;)
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify it worked:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status docker
sudo docker run hello-world
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;creating-a-non-root-admin-user&quot;&gt;Creating a Non-Root Admin User&lt;/h3&gt;
&lt;p&gt;Coolify's own docs assume you're using the root account. Since root login is disabled as part of the security hardening earlier in this series, we create a dedicated user with passwordless sudo instead.&lt;/p&gt;
&lt;p&gt;Run the second playbook to create that user:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ansible-playbook ./playbooks/basic-secure.yml --user root -e &quot;ansible_port=22&quot; --ask-pass --ask-become-pass -i ./inventory/hosts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then set up that user's SSH directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /home/your-sudo-user/.ssh
sudo touch /home/your-sudo-user/.ssh/authorized_keys
sudo chmod 700 /home/your-sudo-user/.ssh
sudo chmod 600 /home/your-sudo-user/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;setting-up-coolify-s-directory-structure&quot;&gt;Setting Up Coolify's Directory Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance}
sudo mkdir -p /data/coolify/ssh/{keys,mux}
sudo mkdir -p /data/coolify/proxy/dynamic
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;generating-and-adding-an-ssh-key&quot;&gt;Generating and Adding an SSH Key&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo ssh-keygen -f /data/coolify/ssh/keys/*Emails are not allowed*rnal -t ed25519 -N '' -C your-sudo-user@coolify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the public key to the authorized_keys file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /data/coolify/ssh/keys/*Emails are not allowed* | sudo tee -a /home/your-sudo-user/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;pulling-coolify-s-configuration-files&quot;&gt;Pulling Coolify's Configuration Files&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo curl -fsSL https://cdn.coollabs.io/coolify/docker-compose.yml -o /data/coolify/source/docker-compose.yml
sudo curl -fsSL https://cdn.coollabs.io/coolify/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
sudo curl -fsSL https://cdn.coollabs.io/coolify/.env.production -o /data/coolify/source/.env
sudo curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;generating-secure-environment-values&quot;&gt;Generating Secure Environment Values&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Only run these once, on first install.&lt;/strong&gt; Changing them later can break Coolify. Back them up somewhere safe.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo sed -i &quot;s|APP_ID=.*|APP_ID=$(openssl rand -hex 16)|g&quot; /data/coolify/source/.env
sudo sed -i &quot;s|APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|g&quot; /data/coolify/source/.env
sudo sed -i &quot;s|DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|g&quot; /data/coolify/source/.env
sudo sed -i &quot;s|REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|g&quot; /data/coolify/source/.env
sudo sed -i &quot;s|PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|g&quot; /data/coolify/source/.env
sudo sed -i &quot;s|PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|g&quot; /data/coolify/source/.env
sudo sed -i &quot;s|PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|g&quot; /data/coolify/source/.env
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;permissions-and-docker-setup&quot;&gt;Permissions and Docker Setup&lt;/h3&gt;
&lt;p&gt;Set correct ownership and permissions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown -R 9999:root /data/coolify
sudo find /data/coolify -type d -exec chmod 755 {} \;
sudo find /data/coolify -type f -exec chmod 644 {} \;
sudo chown -R your-sudo-user:your-sudo-user /home/your-sudo-user/.ssh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the Docker network Coolify expects:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo docker network create --attachable coolify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add your user to the Docker group:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo usermod -aG docker your-sudo-user
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;starting-coolify&quot;&gt;Starting Coolify&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --pull always --remove-orphans --force-recreate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confirm it's running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then visit &lt;code&gt;http://YOUR-SERVER-IP:8000&lt;/code&gt; in your browser.&lt;/p&gt;
&lt;h3 id=&quot;setup-your-ssl-certificates&quot;&gt;Setup your SSL Certificates&lt;/h3&gt;
&lt;p&gt;Coolify's reverse-proxy Traefik does this under the hood automatically.&lt;/p&gt;
&lt;p&gt;In your DNS records add two A records.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A     @     your-vps-ip-address        14400
A     *     your-vps-ip-address        14400
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In your Coolify dashboard go to settings in the left side column. In the input box marked URL type your https subdomain for Coolify there.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://coolify.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Traefik will automatically apply a Let's Encrypt certificate to your Coolify subdomain.&lt;/p&gt;
&lt;h3 id=&quot;disable-port-8000&quot;&gt;Disable Port 8000&lt;/h3&gt;
&lt;p&gt;Disable port 8000 on your VPS. If your hosting provider allows you to configure a firewall from your dashboard, disable it from there. To disable port 8000, simply write rules that allow the ports you want and block everything else.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up&quot;&gt;Wrap-Up&lt;/h3&gt;
&lt;p&gt;That's a full manual Coolify install on a hardened, non-root VPS — no automated script required. From here, Coolify handles the rest: connecting your Git repos, setting up applications, and managing deployments.&lt;/p&gt;
&lt;p&gt;If you hit issues with the automated script on a newer Ubuntu release, this manual path should get you unblocked. Questions or corrections welcome in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23307/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-wont-cut-it</guid>
<pubDate>Thu, 23 Jul 2026 05:15:31 +0000</pubDate>
</item>
<item>
<title>The Ansible Playbook that will Harden Your VPS</title>
<link>https://coderlegion.com/22679/the-ansible-playbook-that-will-harden-your-vps</link>
<description>&lt;h2 id=&quot;ansible-playbooks-automate-vps-hardening-user-creation-amp-removal&quot;&gt;Ansible Playbooks — Automate VPS Hardening, User Creation &amp;amp; Removal&lt;/h2&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=vl8IW8F1mxA&quot;&gt;Link to youtube Video on these playbooks&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;In this post I'm sharing 3 Ansible playbooks I use to manage my VPS servers. I won't go into great detail on tasks here — if you're new to Ansible check out my previous post and video first.&lt;/p&gt;
&lt;p&gt;The three playbooks covered today:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;basic-secure.yml&lt;/code&gt; — automate VPS hardening&lt;/li&gt;
&lt;li&gt;&lt;code&gt;add-vps-user.yml&lt;/code&gt; — semi-automated user creation&lt;/li&gt;
&lt;li&gt;&lt;code&gt;remove-vps-user.yml&lt;/code&gt; — completely remove a user and their privileges&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;📚 &lt;strong&gt;Resources:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.ansible.com&quot;&gt;Ansible Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.ansible.com/ansible/latest/collections/index.html&quot;&gt;Ansible Collection Index&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-basics&quot;&gt;The Basics&lt;/h3&gt;
&lt;p&gt;A task is broken up into four parts:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Part &lt;/th&gt;&lt;th&gt;Description&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Name&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;A plain-text description of what the task does&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Collection&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;The Ansible content bundle the module belongs to&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Module&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;The tool that executes the action&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Parameters&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;The specific options passed to the module&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;playbook-1-basic-secure-yml&quot;&gt;Playbook 1 — basic-secure.yml&lt;/h3&gt;
&lt;p&gt;This playbook fully hardens a fresh Ubuntu VPS in a single command. It prompts you for a custom admin username, generates a random 16-character password, configures UFW, installs Fail2Ban, and moves SSH to port 2222.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;---
- name: Harden Ubuntu VPS Security Configuration
  hosts: vpsDemo
  gather_facts: true
  vars_prompt:
    - name: 'custom_admin_user'
      prompt: 'Enter the custom username for your main administrator account'
      private: false
  tasks:
    # 1. GENERATE RANDOM PASSWORD
    - name: Generate random password
      set_fact:
        new_admin_password: &quot;{{ lookup('password', '/dev/null chars=ascii_letters,digits,hexdigits length=16') }}&quot;

    # 2. CREATE SUDO USER
    - name: Ensure the custom admin user exists
      ansible.builtin.user:
        name: '{{ custom_admin_user }}'
        password: &quot;{{ new_admin_password | password_hash('sha512') }}&quot;
        shell: /bin/bash
        state: present
        groups: sudo
        append: true

    - name: Allow the admin user to use sudo without a password prompt
      ansible.builtin.copy:
        content: &quot;{{ custom_admin_user }} ALL=(ALL) NOPASSWD:ALL\n&quot;
        dest: '/etc/sudoers.d/{{ custom_admin_user }}'
        mode: '0440'
        validate: /usr/sbin/visudo -cf %s

    # 3. CONFIGURE UFW FIREWALL
    - name: Reset UFW to default settings
      community.general.ufw:
        state: reset

    - name: Set UFW default policies to deny incoming
      community.general.ufw:
        policy: deny
        direction: incoming

    - name: Open Port 80 (HTTP)
      community.general.ufw:
        rule: allow
        port: '80'
        proto: tcp

    - name: Open Port 443 (HTTPS)
      community.general.ufw:
        rule: allow
        port: '443'
        proto: tcp

    - name: Open Custom SSH Port 2222
      community.general.ufw:
        rule: allow
        port: '2222'
        proto: tcp

    - name: Enable UFW Firewall
      community.general.ufw:
        state: enabled

    # 4. INSTALL FAIL2BAN
    - name: Install Fail2Ban
      ansible.builtin.apt:
        name: fail2ban
        state: present
        update_cache: true

    - name: Ensure Fail2Ban is running and enabled on boot
      ansible.builtin.service:
        name: fail2ban
        state: started
        enabled: true

    # 5. HARDEN SSH
    - name: Configure SSH to use custom port 2222
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?Port\s'
        line: 'Port 2222'
        state: present

    - name: Disable Root SSH Login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin\s'
        line: 'PermitRootLogin no'
        state: present

    # 6. FIX SYSTEMD SSH SOCKET
    - name: Create systemd override directory for SSH socket
      ansible.builtin.file:
        path: /etc/systemd/system/ssh.socket.d
        state: directory
        mode: '0755'

    - name: Write dual-stack IPv4/IPv6 socket configuration
      ansible.builtin.copy:
        dest: /etc/systemd/system/ssh.socket.d/listen.conf
        mode: '0644'
        content: |
          [Socket]
          ListenStream=
          ListenStream=0.0.0.0:2222
          ListenStream=[::]:2222

    - name: Reload systemd daemon
      ansible.builtin.systemd_service:
        daemon_reload: true

    - name: Restart SSH socket
      ansible.builtin.service:
        name: ssh.socket
        state: restarted

    - name: Restart SSH service
      ansible.builtin.service:
        name: ssh
        state: restarted

    # 7. DISPLAY CREDENTIALS
    - name: Display your new credentials (SAVE THESE IMMEDIATELY)
      ansible.builtin.debug:
        msg:
          - '========================================================'
          - 'NEW SUDO USERNAME: {{ custom_admin_user }}'
          - 'NEW PASSWORD: {{ new_admin_password }}'
          - 'CUSTOM SSH PORT: 2222'
          - '========================================================'
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Save the displayed credentials immediately&lt;/strong&gt; — the generated password is only shown once.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;playbook-2-add-vps-user-yml&quot;&gt;Playbook 2 — add-vps-user.yml&lt;/h3&gt;
&lt;p&gt;Creates a new sudo user with a randomly generated secure password and prints the credentials to your screen.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;---
- name: Universal Semi-Automated User Creation Script
  hosts: vpsDemo
  gather_facts: false
  become: true
  vars_prompt:
    - name: 'custom_admin_user'
      prompt: 'Enter the custom username for this new administrator account'
      private: false
  tasks:
    - name: Generate random secure password
      set_fact:
        new_random_password: &quot;{{ lookup('password', '/dev/null chars=ascii_letters,digits length=16') }}&quot;

    - name: Ensure the new user account exists
      ansible.builtin.user:
        name: '{{ custom_admin_user }}'
        password: &quot;{{ new_random_password | password_hash('sha512') }}&quot;
        shell: /bin/bash
        state: present
        groups: sudo
        append: true

    - name: Allow the new user to use sudo without a password prompt
      ansible.builtin.copy:
        content: &quot;{{ custom_admin_user }} ALL=(ALL) NOPASSWD:ALL\n&quot;
        dest: '/etc/sudoers.d/{{ custom_admin_user }}'
        mode: '0440'
        validate: /usr/sbin/visudo -cf %s

    - name: Display New User Credentials
      ansible.builtin.debug:
        msg:
          - '========================================================'
          - 'NEW ADMINISTRATIVE ACCOUNT CREATED SUCCESSFULLY!'
          - 'USERNAME: {{ custom_admin_user }}'
          - 'PASSWORD: {{ new_random_password }}'
          - '========================================================'
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;playbook-3-remove-vps-user-yml&quot;&gt;Playbook 3 — remove-vps-user.yml&lt;/h3&gt;
&lt;p&gt;Completely purges a user account, their home directory, mail spool, and sudo privileges from the server.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;---
- name: Universal User and Privilege Removal Script
  hosts: all
  gather_facts: false
  become: true
  vars_prompt:
    - name: 'user_to_delete'
      prompt: 'Enter the exact username you want to COMPLETELY delete'
      private: false
  tasks:
    - name: Delete the user's custom sudoers configuration file
      ansible.builtin.file:
        path: '/etc/sudoers.d/{{ user_to_delete }}'
        state: absent

    - name: Remove the user account and purge their files
      ansible.builtin.user:
        name: '{{ user_to_delete }}'
        state: absent
        remove: true   # deletes home directory and mail spool
        force: true    # kills any active processes owned by the user
        ignore_errors: true

    - name: Display Removal Confirmation
      ansible.builtin.debug:
        msg:
          - '========================================================'
          - &quot;SUCCESS: Account '{{ user_to_delete }}' and their sudo privileges&quot;
          - 'have been completely purged from the server.'
          - '========================================================'
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;I hope these playbooks are useful — grab them, adapt them to your environment and save yourself hours of repetitive manual work. I'll be sharing more playbooks as I build them out.&lt;/p&gt;
&lt;p&gt;Blessings. 🙏&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Looking for developer templates built on TanStack, Directus, and Tailwind CSS?&lt;/em&gt;&lt;br&gt;
&lt;em&gt;🛒 &lt;a rel=&quot;nofollow&quot; href=&quot;https://northernrangedigital.lemonsqueezy.com/&quot;&gt;northernrangedigital.lemonsqueezy.com&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;🌐 &lt;a rel=&quot;nofollow&quot; href=&quot;https://northernrangedigital.com&quot;&gt;northernrangedigital.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22679/the-ansible-playbook-that-will-harden-your-vps</guid>
<pubDate>Tue, 14 Jul 2026 21:06:17 +0000</pubDate>
</item>
</channel>
</rss>