<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wade Thomas</title>
    <description>The latest articles on DEV Community by Wade Thomas (@wadethomastt).</description>
    <link>https://dev.to/wadethomastt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3850437%2F8d98205a-0b4e-4e70-abeb-0bb759317abf.jpg</url>
      <title>DEV Community: Wade Thomas</title>
      <link>https://dev.to/wadethomastt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wadethomastt"/>
    <language>en</language>
    <item>
      <title>TanStack Start Basics Part 2 — Connecting to Your Directus Backend</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Thu, 10 Sep 2026 22:00:04 +0000</pubDate>
      <link>https://dev.to/wadethomastt/tanstack-start-basics-part-2-connecting-to-your-directus-backend-3bjj</link>
      <guid>https://dev.to/wadethomastt/tanstack-start-basics-part-2-connecting-to-your-directus-backend-3bjj</guid>
      <description>&lt;p&gt;Our frontend has been static so far — just hardcoded headings on a few pages. Today we connect it to the Directus backend built earlier in this series, using the official Directus SDK to pull real product data into our TanStack Start app.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/BUULSpRQiAY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing the Directus SDK
&lt;/h2&gt;

&lt;p&gt;There are a few ways to talk to Directus from a frontend — the REST API directly, GraphQL — but the SDK is the cleanest option, and what we're using here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @directus/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating the Directus Helper
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;src/lib&lt;/code&gt;, create a new file: &lt;code&gt;directus.ts&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createDirectus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@directus/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;directus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDirectus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your_Directus_Project_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createDirectus&lt;/code&gt; initializes a connection client between your app and your Directus instance. On its own it starts empty — capabilities get added by chaining &lt;code&gt;.with()&lt;/code&gt;. Since we're fetching over standard HTTP REST requests, we pair it with the &lt;code&gt;rest()&lt;/code&gt; modifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining Our First Type
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;src&lt;/code&gt;, create &lt;code&gt;types/index.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Products&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;isOnSale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Writing the Fetch Function
&lt;/h2&gt;

&lt;p&gt;Back in &lt;code&gt;directus.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createDirectus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readItems&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@directus/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Products&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;directus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDirectus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your_Directus_Project_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Products&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;directus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;readItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brief&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;isOnSale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;readItems&lt;/code&gt; is a built-in SDK helper that generates a request for fetching multiple items from a given collection. At this point, TypeScript throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Type 'Record&amp;lt;string, any&amp;gt;[]' is not assignable to type 'Products[]'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fixing the Schema Error
&lt;/h2&gt;

&lt;p&gt;This happens because the SDK doesn't automatically know your database's actual shape — it needs a &lt;code&gt;Schema&lt;/code&gt; type passed into &lt;code&gt;createDirectus&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Products&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;directus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createDirectus&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your_Directus_Project_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This declares that a &lt;code&gt;products&lt;/code&gt; collection exists and maps to an array of &lt;code&gt;Products&lt;/code&gt;. With this in place, the error resolves — and as a bonus, TypeScript now validates and autocompletes the &lt;code&gt;fields&lt;/code&gt; array against this type too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching the Data — Method 1: TanStack Query
&lt;/h2&gt;

&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @tanstack/react-query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it up in &lt;code&gt;__root.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;QueryClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;QueryClientProvider&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/react-query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Create a client&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;head&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HeadContent&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;head&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QueryClientProvider&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;QueryClientProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Scripts&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;RootDocument&lt;/code&gt; take a &lt;code&gt;children&lt;/code&gt; prop, and why wrap it?&lt;/strong&gt; &lt;code&gt;__root.tsx&lt;/code&gt; wraps your entire application — every route (About, Index, Products) gets passed in as &lt;code&gt;children&lt;/code&gt;, rendered inside this HTML shell. Wrapping &lt;code&gt;children&lt;/code&gt; with the query provider means every nested route and component now has access to TanStack Query.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now build the component, &lt;code&gt;src/components/Products.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/react-query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Products&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;gcTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refetchOnWindowFocus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refetchOnMount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"container mx-auto p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"container mx-auto p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-4xl font-medium mb-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Products&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"border rounded-lg p-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-bold mb-2"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick rundown of the query options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;staleTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long fetched data stays "fresh" (5 min) before a refetch is considered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gcTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long unused data stays cached in memory (15 min) before being cleared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refetchOnWindowFocus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether switching tabs and back triggers a refetch (off here)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;retry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent retry attempts on failure before giving up (2 here)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refetchOnMount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether the component checks for stale data and refetches on every mount (on here)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wire it into the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Products&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/Products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createFileRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/react-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createFileRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)({&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RouteComponent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-5xl text-slate-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Products&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Products&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;localhost:3000/products&lt;/code&gt; — real product data, pulled live from Directus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching the Data — Method 2: Route Loaders
&lt;/h2&gt;

&lt;p&gt;Loaders work a little differently, and only on routes directly — not on standalone components. Remove the &lt;code&gt;Products&lt;/code&gt; component from the route and build it in directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createFileRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/react-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getProducts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/directus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createFileRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RouteComponent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useLoaderData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-5xl text-slate-700 mb-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Products&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"border rounded-lg p-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-bold mb-2"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loader runs &lt;code&gt;getProducts&lt;/code&gt; before the route renders, and &lt;code&gt;Route.useLoaderData()&lt;/code&gt; pulls that resolved data straight into the component — no loading state needed, since the data's already there by the time the page shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which One Should You Use?
&lt;/h2&gt;

&lt;p&gt;Both approaches call the exact same &lt;code&gt;getProducts&lt;/code&gt; function underneath — the difference is &lt;em&gt;where&lt;/em&gt; the fetching happens and &lt;em&gt;how&lt;/em&gt; the result reaches your component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TanStack Query&lt;/strong&gt; shines for data that changes often or benefits from caching, background refetching, and built-in loading states — great for a snappy feel on repeat visits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route loaders&lt;/strong&gt; fetch before the route even renders, so there's no loading spinner at all. That pairs particularly well with TanStack Start's server-side rendering model, since the data can be resolved as part of the initial render rather than after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In this part, we:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Installed and configured the Directus SDK&lt;/li&gt;
&lt;li&gt;Typed our data with a &lt;code&gt;Products&lt;/code&gt; interface and a &lt;code&gt;Schema&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Built our first real fetch function using &lt;code&gt;readItems&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rendered a live products grid two different ways — TanStack Query and a route loader&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right now we're only displaying each product's name, but this same pattern carries directly into fuller product cards next — pricing, images, sale badges, and eventually branding and tags once those relational fields come into play.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tanstack</category>
      <category>directus</category>
      <category>react</category>
      <category>tailwindcss</category>
    </item>
    <item>
      <title>Setting Up Our Frontend with TanStack Start</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:41:39 +0000</pubDate>
      <link>https://dev.to/wadethomastt/setting-up-our-frontend-with-tanstack-start-3ok0</link>
      <guid>https://dev.to/wadethomastt/setting-up-our-frontend-with-tanstack-start-3ok0</guid>
      <description>&lt;p&gt;We've finished our backend across this series — a Directus instance running on a VPS managed by Coolify, backed by PostgreSQL and Redis, with a products collection already built and secured with roles and permissions.&lt;/p&gt;

&lt;p&gt;Now it's time to build a frontend to actually display that data. For this, we're using &lt;strong&gt;TanStack Start&lt;/strong&gt;, &lt;strong&gt;Tailwind CSS&lt;/strong&gt;, and &lt;strong&gt;shadcn/ui&lt;/strong&gt;. This post covers getting TanStack Start installed and running.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/vzN_njItJFo" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is TanStack Start?
&lt;/h2&gt;

&lt;p&gt;Straight from the &lt;a href="https://tanstack.com/start/latest/docs/framework/react/overview" rel="noopener noreferrer"&gt;official docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;TanStack Start is a full-stack React framework powered by TanStack Router. It provides full-document SSR, streaming, server functions, client/server builds, and more. With Vite and Rsbuild support, it's ready to develop and deploy to the hosting provider or runtime you want.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In short: it offers the same kind of full-stack capability as frameworks like Next.js or Remix, built on top of TanStack Router.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Project
&lt;/h2&gt;

&lt;p&gt;First, create a folder for your app. I'm naming mine &lt;code&gt;app_fe&lt;/code&gt; — "fe" for front end.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Naming convention tip:&lt;/strong&gt; I use &lt;code&gt;&amp;lt;app-name&amp;gt;_fe&lt;/code&gt; for all my frontend project folders — so an e-commerce project would be &lt;code&gt;store_fe&lt;/code&gt;. Makes the folder's purpose obvious at a glance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From inside that folder, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @tanstack/cli@latest create &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note the trailing dot.&lt;/strong&gt; Since we created the folder ourselves beforehand, the dot tells the CLI to install directly into the current directory. Omit the dot if you'd rather the CLI create a new folder for you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Walking Through the Setup Prompts
&lt;/h2&gt;

&lt;p&gt;The CLI will walk you through a series of choices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;Selection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Select Framework&lt;/td&gt;
&lt;td&gt;React&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Select Toolchain&lt;/td&gt;
&lt;td&gt;ESLint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Select Deployment Adapter&lt;/td&gt;
&lt;td&gt;None (self-hosting on our own VPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Include demo/example pages?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What add-ons would you like?&lt;/td&gt;
&lt;td&gt;shadcn/ui&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Initialize a new git repository?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continue with these settings?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wait for the install to finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the App
&lt;/h2&gt;

&lt;p&gt;Navigate into your app directory (if you aren't already there) and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open your browser to &lt;code&gt;http://localhost:3000&lt;/code&gt;. Congratulations — your TanStack Start app is up and running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing the Home Page
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;src/routes/index.tsx&lt;/code&gt;. Delete everything inside the div, and save — your home page should now be completely blank. This file is your landing page, the first thing visitors see.&lt;/p&gt;

&lt;p&gt;Add an &lt;code&gt;h1&lt;/code&gt; tag inside the empty div:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save, and you'll see "Home" rendered on the page. Now let's add some Tailwind styling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-5xl text-slate-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save again — notice Tailwind works right out of the box, no extra configuration needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  File-Based Routing
&lt;/h2&gt;

&lt;p&gt;TanStack Start runs on TanStack Router, which uses &lt;strong&gt;file-based routing&lt;/strong&gt;. Every file added to the &lt;code&gt;routes&lt;/code&gt; folder automatically becomes a navigable page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding an About Page
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;src/routes/about.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-5xl text-slate-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save, then visit &lt;code&gt;http://localhost:3000/about&lt;/code&gt;. Notice the home page lives at &lt;code&gt;localhost:3000/&lt;/code&gt;, and the about page lives at &lt;code&gt;localhost:3000/about&lt;/code&gt; — TanStack Start handled that routing automatically, purely based on the filename.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a Products Page
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;src/routes/products.tsx&lt;/code&gt; the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-5xl text-slate-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Products&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;localhost:3000/products&lt;/code&gt;, and there's your new page. That's as simple as basic routing gets — there's plenty more you can do with nested and dynamic routes, which we'll cover as it becomes relevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is That &lt;code&gt;__root.tsx&lt;/code&gt; File?
&lt;/h2&gt;

&lt;p&gt;You've probably noticed a file called &lt;code&gt;__root.tsx&lt;/code&gt; sitting in your routes folder. Here's the simplest way to think about it:&lt;/p&gt;

&lt;p&gt;Picture any large website — Amazon, YouTube, anything. Every page has a header up top and often a footer at the bottom that never change, no matter what page you're on. But the content in the middle changes depending on where you navigated.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;__root.tsx&lt;/code&gt; is the file that builds that "never changes" part. It's the frame around every page in your app. Every route you build — home, about, products — gets rendered &lt;em&gt;inside&lt;/em&gt; this one file. Write it once, and every page automatically inherits it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A word of caution:&lt;/strong&gt; this file is central to how TanStack Router works under the hood. Avoid modifying it until you understand exactly what each piece does — we'll take a closer look at it in an upcoming post, exactly when it becomes relevant to what we're building.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In this part, we:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Installed and configured a new TanStack Start project&lt;/li&gt;
&lt;li&gt;Ran it locally and confirmed Tailwind works out of the box&lt;/li&gt;
&lt;li&gt;Learned file-based routing by building About and Products pages&lt;/li&gt;
&lt;li&gt;Got introduced to &lt;code&gt;__root.tsx&lt;/code&gt;, the file behind every page in the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That wraps up our frontend setup. Next up, we'll start pulling real data from our Directus backend into this frontend — connecting everything we've built across this series.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>react</category>
      <category>directus</category>
      <category>tanstack</category>
    </item>
    <item>
      <title>Directus Basics Part 3 — User Roles &amp; Permissions</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:08:46 +0000</pubDate>
      <link>https://dev.to/wadethomastt/directus-basics-part-3-user-roles-permissions-328c</link>
      <guid>https://dev.to/wadethomastt/directus-basics-part-3-user-roles-permissions-328c</guid>
      <description>&lt;p&gt;This is part three of our Directus Basics series. In &lt;a href="https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;amp;t=12s" rel="noopener noreferrer"&gt;part one&lt;/a&gt; we set up our instance, and in &lt;a href="https://www.youtube.com/watch?v=7SNrBzaxreg&amp;amp;t=108s" rel="noopener noreferrer"&gt;part two&lt;/a&gt; we covered relationships between collections. Today we're covering access control — what determines who can see and touch your data.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/qAk3X4y8v1U"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Concepts
&lt;/h2&gt;

&lt;p&gt;Access control in Directus comes down to three terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission&lt;/strong&gt; — applies to one collection and one action (create, read, update, delete, or share). Can be full access, no access, or custom rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy&lt;/strong&gt; — a group of permissions bundled together, applied to users or roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role&lt;/strong&gt; — defines a user's position within a project. A role can hold any number of policies, apply to any number of users, and have child roles of its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Happens Without a Role?
&lt;/h2&gt;

&lt;p&gt;If an administrator creates a user but doesn't assign a role, that user has valid login credentials but still can't access the Data Studio.&lt;/p&gt;

&lt;p&gt;Creating a role and assigning the user to it isn't enough either — logging in at that point returns a &lt;strong&gt;"No App Access"&lt;/strong&gt; error. The missing piece is that the role has no &lt;strong&gt;access policy&lt;/strong&gt; attached. The policy is what actually tells the role what its users can and can't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Access Order
&lt;/h2&gt;

&lt;p&gt;Access in Directus flows in a specific direction:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access Policy → Role → User&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In practice: define what level of access a user needs, build an access policy to match, create a role and attach that policy to it, then register the user and assign them to the role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Default Policies
&lt;/h2&gt;

&lt;p&gt;Every fresh Directus instance ships with two default policies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Administrator&lt;/strong&gt; — the role you're signed in as by default, with unrestricted access to everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public&lt;/strong&gt; — for data that should be visible without logging in. Think of a product catalog on an e-commerce site — forcing a login just to browse products is a poor experience.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; give the Public policy Read access only. Never Create, Update, or Delete. The public should be able to view data, never manipulate it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Giving a Collection Public Access
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Access Policies&lt;/strong&gt; → &lt;strong&gt;Public&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Under Permissions, click &lt;strong&gt;Add Collection&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;products&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Read&lt;/strong&gt;, choose &lt;strong&gt;All Access&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Creating a Custom Access Policy
&lt;/h2&gt;

&lt;p&gt;Let's build a policy for a data-entry team member with limited access.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Access Policies&lt;/strong&gt;, click &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;DataEntry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;App Access&lt;/strong&gt; — leave &lt;strong&gt;Admin Access&lt;/strong&gt; unchecked, since that grants unrestricted control&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Directus populates the policy with minimum defaults for its own system collections, but nothing for your custom collections yet.&lt;/p&gt;

&lt;p&gt;Add products to it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Add Collection&lt;/strong&gt;, select &lt;code&gt;products&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Read&lt;/strong&gt;, choose &lt;strong&gt;All Access&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Creating the User Role
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;User Roles&lt;/strong&gt;, click &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;DataEntry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Under Policies, click &lt;strong&gt;Add Existing&lt;/strong&gt;, select the &lt;code&gt;DataEntry&lt;/code&gt; policy&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Creating the User
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;User Directory&lt;/strong&gt;, click &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Fill in first name, last name, email, and password&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Role&lt;/strong&gt;, select &lt;code&gt;DataEntry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you check back on &lt;strong&gt;User Roles&lt;/strong&gt;, you'll see a &lt;code&gt;1&lt;/code&gt; next to DataEntry under Users, and a matching &lt;code&gt;1&lt;/code&gt; under Access Policies. That confirms the role, policy, and user are all wired together correctly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; giving your policy and role the same name (as done here with "DataEntry") makes them much easier to track as your project grows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Testing Read-Only Access
&lt;/h2&gt;

&lt;p&gt;Log in as the new user. You'll see the products collection, but attempting to edit any field does nothing — expected, since only Read access was granted.&lt;/p&gt;

&lt;p&gt;Scrolling to the bottom of a product's detail page, some data is simply missing — those fields belong to &lt;em&gt;other&lt;/em&gt; collections the user doesn't yet have permission for. The product image is missing too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Missing File Permission
&lt;/h2&gt;

&lt;p&gt;Back in the &lt;code&gt;DataEntry&lt;/code&gt; policy, notice &lt;strong&gt;&lt;code&gt;directus_files&lt;/code&gt;&lt;/strong&gt; isn't included among the accessible system collections. Without it, uploaded files — including product images — stay invisible.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Add Collection&lt;/strong&gt;, select &lt;code&gt;directus_files&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Read&lt;/strong&gt;, choose &lt;strong&gt;All Access&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Log back in as the data-entry user, and the product image now appears.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding the Thumbnail to List View
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;In list view, click the &lt;strong&gt;+&lt;/strong&gt; icon on the right&lt;/li&gt;
&lt;li&gt;Scroll to &lt;strong&gt;image&lt;/strong&gt;, click the arrow next to it — not the field name itself, or you'll add the raw file ID instead of the thumbnail&lt;/li&gt;
&lt;li&gt;Scroll to &lt;strong&gt;thumbnail&lt;/strong&gt;, click outside the dropdown to confirm&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Drag column headers to reorder them as needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending Access to Related Collections
&lt;/h2&gt;

&lt;p&gt;The same missing-permission pattern applies to &lt;strong&gt;branding&lt;/strong&gt;, &lt;strong&gt;products_tags&lt;/strong&gt; and &lt;strong&gt;tags&lt;/strong&gt; — add all to the &lt;code&gt;DataEntry&lt;/code&gt; policy with Read access, and the relational fields on products become visible.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; you can't access a relational field in one collection unless you also have permission on the &lt;em&gt;related&lt;/em&gt; collection. Both sides of the relationship need coverage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Custom Field-Level Permissions
&lt;/h2&gt;

&lt;p&gt;Sometimes all-or-nothing collection access isn't granular enough. Directus lets you restrict access down to individual fields.&lt;/p&gt;

&lt;p&gt;Let's allow the data-entry user to update only the &lt;code&gt;name&lt;/code&gt; field on products:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the products collection, click the &lt;strong&gt;Update&lt;/strong&gt; action&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Use Custom&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Field Permissions&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;Name&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Save, then save again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Logging back in, the data-entry user can now edit the Name field — and nothing else on that collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Permission&lt;/td&gt;
&lt;td&gt;One collection + one action, set to full/none/custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy&lt;/td&gt;
&lt;td&gt;A bundle of permissions, applied to users or roles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role&lt;/td&gt;
&lt;td&gt;A user's position in the project; holds policies, applies to users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Access control in Directus is genuinely one of its most powerful features — this covers the fundamentals, but there's plenty more granularity available as your project grows.&lt;/p&gt;

&lt;p&gt;That wraps part three. Next up, we'll look at how everything from this series comes together once we start pulling this data into a real front end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>directus</category>
      <category>role</category>
      <category>accesspolicy</category>
      <category>permissions</category>
    </item>
    <item>
      <title>Directus Basics Part 2 — Understanding Relationships (M2O, O2M, M2M)</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:22:42 +0000</pubDate>
      <link>https://dev.to/wadethomastt/directus-basics-part-2-understanding-relationships-m2o-o2m-m2m-1lom</link>
      <guid>https://dev.to/wadethomastt/directus-basics-part-2-understanding-relationships-m2o-o2m-m2m-1lom</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/7SNrBzaxreg"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;This is part two of our Directus Basics series. In &lt;a href="https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;amp;t=6s" rel="noopener noreferrer"&gt;part one&lt;/a&gt;, we set up a Directus instance and created our first collection. Today we're covering one of the most powerful features Directus offers: &lt;strong&gt;relationships&lt;/strong&gt; between collections.&lt;/p&gt;

&lt;p&gt;By the end of this article, you'll understand three core relationship types — Many to One (M2O), One to Many (O2M), and Many to Many (M2M). There's a fourth type, Many to Any (M2A), which we'll cover in a future post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Branding Collection
&lt;/h2&gt;

&lt;p&gt;Continuing from our existing Directus instance, let's create a new collection called &lt;strong&gt;branding&lt;/strong&gt;. Select all the additional default fields Directus offers.&lt;/p&gt;

&lt;p&gt;Add two fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String (basic input)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text (textarea)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once the collection is set up, add a few entries — I'd recommend at least two different brands so you can see the relationship in action once it's wired up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Many to One (M2O): Products → Branding
&lt;/h2&gt;

&lt;p&gt;Head over to your &lt;strong&gt;products&lt;/strong&gt; collection. We want to connect a product to a brand.&lt;/p&gt;

&lt;p&gt;Logically: many products can belong to &lt;em&gt;one&lt;/em&gt; brand. That's a Many to One relationship — many products, to one brand.&lt;/p&gt;

&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create Field → Relational category → &lt;strong&gt;Many to One&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Key: &lt;code&gt;brand_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Related Collection: &lt;code&gt;branding&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;De-select &lt;strong&gt;Enable Create Button&lt;/strong&gt; — you always want to choose a brand from an existing list, not create one on the fly&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  One to Many (O2M): Branding → Products
&lt;/h2&gt;

&lt;p&gt;Since one brand can have &lt;em&gt;many&lt;/em&gt; products, we need to set up the reverse relationship explicitly on the &lt;strong&gt;branding&lt;/strong&gt; collection.&lt;/p&gt;

&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create Field → Relational category → &lt;strong&gt;One to Many&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Key: &lt;code&gt;products&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Related Collection: &lt;code&gt;products&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Foreign Key: &lt;code&gt;brand_id&lt;/code&gt; — the field we just created on the products side&lt;/li&gt;
&lt;li&gt;De-select &lt;strong&gt;Enable Create Button&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Seeing the Relationship in Action
&lt;/h2&gt;

&lt;p&gt;Go back into your &lt;strong&gt;products&lt;/strong&gt; collection and assign a brand to each product. Make sure at least two products share the same brand so the relationship is visible.&lt;/p&gt;

&lt;p&gt;You'll notice each product can only belong to &lt;strong&gt;one&lt;/strong&gt; brand — that's the M2O relationship at work.&lt;/p&gt;

&lt;p&gt;Now open the &lt;strong&gt;branding&lt;/strong&gt; collection and click into one of your brands. You'll see the products you just assigned show up automatically — you didn't have to add them manually. Directus handled that because of the relationship you configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Many to Many (M2M): Products ↔ Tags
&lt;/h2&gt;

&lt;p&gt;M2M relationships work differently — both sides can have many related records.&lt;/p&gt;

&lt;p&gt;A common real-world example: &lt;strong&gt;tags&lt;/strong&gt; on products, used to improve SEO. A single product can have multiple tags, and a single tag can apply to many products.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the Tags Collection
&lt;/h3&gt;

&lt;p&gt;Create a new collection called &lt;strong&gt;tags&lt;/strong&gt;, with the same two fields as before:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text (textarea)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Add three or four tags before moving on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wire Up the M2M Relationship
&lt;/h3&gt;

&lt;p&gt;Back in the &lt;strong&gt;products&lt;/strong&gt; collection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create Field → Relational category → &lt;strong&gt;Many to Many&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Key: &lt;code&gt;tags&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Related Collection: &lt;code&gt;tags&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;De-select &lt;strong&gt;Enable Create Button&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not save yet&lt;/strong&gt; — scroll down and click &lt;strong&gt;"Continue in Advanced Field Creation Mode"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;In the sidebar, click &lt;strong&gt;Relationship&lt;/strong&gt;, then find &lt;strong&gt;Corresponding Field&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;"Create Field"&lt;/strong&gt; — under Field Name, you should see &lt;code&gt;products&lt;/code&gt; appear&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why the advanced flow?&lt;/strong&gt; Under Corresponding Field, &lt;code&gt;products&lt;/code&gt; appears automatically — that's Directus telling you it's about to create the matching relational field back on the &lt;strong&gt;tags&lt;/strong&gt; collection. Unlike the M2O/O2M setup, you don't have to manually configure both sides — Directus builds the reverse relationship for you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, when you add a tag to a product, Directus automatically links that product under the corresponding tag in the &lt;strong&gt;tags&lt;/strong&gt; collection — no manual syncing required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Relationship&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;M2O&lt;/td&gt;
&lt;td&gt;Many records → one related record&lt;/td&gt;
&lt;td&gt;Products → Branding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O2M&lt;/td&gt;
&lt;td&gt;One record → many related records&lt;/td&gt;
&lt;td&gt;Branding → Products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M2M&lt;/td&gt;
&lt;td&gt;Many ↔ many, both directions&lt;/td&gt;
&lt;td&gt;Products ↔ Tags&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Relationships are one of those Directus features that make a lot more sense once you've built one yourself than they do reading about them — so I'd recommend replicating this setup in your own instance before moving on.&lt;/p&gt;

&lt;p&gt;That wraps up part two. In part three, we'll cover Directus &lt;strong&gt;permissions&lt;/strong&gt; — how to control exactly who can see and edit your data.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version on &lt;a href="https://www.youtube.com/watch?v=7SNrBzaxreg" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>directus</category>
      <category>sql</category>
      <category>webdev</category>
      <category>headlesscms</category>
    </item>
    <item>
      <title>Directus Collections Explained (Fields, Types &amp; Setup) — Directus Basics Part 1</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Fri, 07 Aug 2026 21:12:39 +0000</pubDate>
      <link>https://dev.to/wadethomastt/directus-collections-explained-fields-types-setup-directus-basics-part-1-of7</link>
      <guid>https://dev.to/wadethomastt/directus-collections-explained-fields-types-setup-directus-basics-part-1-of7</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/tJGuxqhv2SY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is Part 1 of a three-part series on Directus fundamentals: &lt;strong&gt;Collections&lt;/strong&gt; (this post), &lt;strong&gt;Relationships&lt;/strong&gt;, and &lt;strong&gt;Permissions&lt;/strong&gt;. New to Directus? Start here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Collections are the bread and butter of the Directus Data Studio. According to &lt;a href="https://directus.com/docs" rel="noopener noreferrer"&gt;Directus's own docs&lt;/a&gt;, collections are database tables — with extra metadata and configuration layered on top by Directus.&lt;/p&gt;

&lt;p&gt;The part that makes this genuinely beginner-friendly: you never write a single line of SQL. The Data Studio handles all of that for you. Let's build one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Ways to Create a Collection
&lt;/h2&gt;

&lt;p&gt;There are two paths to collection creation inside the Data Studio.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1:&lt;/strong&gt; Log into your Data Studio and look at the sidebar for the icon that looks like a 3D box — that's &lt;strong&gt;Content&lt;/strong&gt;. Click it, and on the main screen you'll see &lt;strong&gt;Create Collection&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2:&lt;/strong&gt; Scroll down the sidebar to the gear icon — &lt;strong&gt;Settings&lt;/strong&gt;. Click in, and at the top you'll find &lt;strong&gt;Data Model&lt;/strong&gt;. Same deal from there — &lt;strong&gt;Create Collection&lt;/strong&gt; is right on the main screen.&lt;/p&gt;

&lt;p&gt;Either path lands you in the same place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a Collection
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Click &lt;strong&gt;Create Collection&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Name it something intuitive — it should describe exactly what data you're storing. For this example, we're creating a collection called &lt;strong&gt;products&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Ignore &lt;strong&gt;Singleton&lt;/strong&gt; for now (more on this below)&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;Type&lt;/strong&gt; dropdown, select &lt;strong&gt;Generated UUID&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;, select all the boxes offered, and click &lt;strong&gt;Finish Setup&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is Singleton?&lt;/strong&gt; You'd only check this if the collection will ever hold exactly one item — not the case here, since we're storing many products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the Generated UUID?&lt;/strong&gt; A unique ID automatically assigned to every item in the collection, so you never have to manage IDs manually.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once created, you land on the collection's screen, ready to build its data structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus:&lt;/strong&gt; under &lt;strong&gt;Collection Setup&lt;/strong&gt;, you can customize how the collection looks — a color, an icon, and a short note describing its purpose. Small detail, but it pays off once your Directus project has a dozen collections in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating Fields Within a Collection
&lt;/h2&gt;

&lt;p&gt;Fields represent the actual data you're storing — in this case, the data that describes a single product. Every item in the collection shares this same structure, just with different values. Here's the full field breakdown for a &lt;strong&gt;products&lt;/strong&gt; collection:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input (string)&lt;/td&gt;
&lt;td&gt;The product's name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brief&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Textarea&lt;/td&gt;
&lt;td&gt;Short description — more room than Input, no special formatting needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WYSIWYG&lt;/td&gt;
&lt;td&gt;Full rich-text formatting — paragraphs, bullet lists, H1–H4 headings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input (decimal)&lt;/td&gt;
&lt;td&gt;See Precision &amp;amp; Scale below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input (decimal)&lt;/td&gt;
&lt;td&gt;Same setup as &lt;code&gt;price&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isOnSale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Boolean&lt;/td&gt;
&lt;td&gt;Toggle the front end can check to decide which price to show&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dropdown&lt;/td&gt;
&lt;td&gt;Product type — e.g. jersey, pants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;quantity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input (integer)&lt;/td&gt;
&lt;td&gt;Minimum value of 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;File&lt;/td&gt;
&lt;td&gt;Product photo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Setting Up the Price Field (Precision &amp;amp; Scale)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;price&lt;/code&gt; field needs a bit of extra configuration. Instead of clicking &lt;strong&gt;Save&lt;/strong&gt; right away, look just below the Save button for &lt;strong&gt;Continue in Advanced Field Creation Mode&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In there, you'll find &lt;strong&gt;Precision&lt;/strong&gt; and &lt;strong&gt;Scale&lt;/strong&gt;, defaulting to &lt;code&gt;10&lt;/code&gt; and &lt;code&gt;5&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precision&lt;/strong&gt; — the total number of digits the field accepts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale&lt;/strong&gt; — how many of those digits sit after the decimal point&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Left at the defaults, you'd get something like &lt;code&gt;12345.67890&lt;/code&gt;. For a price field, that's overkill — we want 7 digits total, 2 of them after the decimal point. So: &lt;strong&gt;Precision = 7&lt;/strong&gt;, &lt;strong&gt;Scale = 2&lt;/strong&gt;. Adjust to fit your own use case if you need more decimal precision.&lt;/p&gt;

&lt;p&gt;Build &lt;code&gt;sale&lt;/code&gt; the exact same way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on relational fields:&lt;/strong&gt; collection relationships (linking one collection to another) are deliberately left out here — that's a big enough topic to earn its own post, and it's exactly what's coming next in this series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Adding Your Data
&lt;/h2&gt;

&lt;p&gt;Head to the sidebar and click the box icon — &lt;strong&gt;Content&lt;/strong&gt;. You'll see your &lt;strong&gt;Products&lt;/strong&gt; collection listed. Click &lt;strong&gt;Create Item&lt;/strong&gt;, and fill in the fields with real data.&lt;/p&gt;

&lt;p&gt;For this example, add a couple of items — a jersey and a pair of jeans — to see the collection actually holding data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;That's the foundation of Directus collections: the core field types, how to configure them, and how to get real data in. Next up in this series: &lt;strong&gt;relationships&lt;/strong&gt; — how collections connect to each other, which is where Directus really starts to shine.&lt;/p&gt;

&lt;p&gt;Questions or stuck on something? Drop a comment below.&lt;/p&gt;

</description>
      <category>directus</category>
      <category>cms</category>
      <category>database</category>
      <category>datastudio</category>
    </item>
    <item>
      <title>Directus + Coolify: Should You Decouple Postgres &amp; Redis?</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Thu, 06 Aug 2026 21:25:39 +0000</pubDate>
      <link>https://dev.to/wadethomastt/directus-coolify-should-you-decouple-postgres-redis-3lma</link>
      <guid>https://dev.to/wadethomastt/directus-coolify-should-you-decouple-postgres-redis-3lma</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/6cMrbfGZqb8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is Part 2 of the Directus + Coolify series. If you're new here, start with &lt;strong&gt;"Secure Your VPS Before Hackers Do"&lt;/strong&gt; and the first Directus + Coolify post — the bundled, single-Compose-file setup — before following along with this one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the first method, we coupled all of the services into one stack using a single Docker Compose file. The network between all services was created automatically, and we didn't have to start them up individually — which removes the risk of a race condition if service startup isn't handled properly.&lt;/p&gt;

&lt;p&gt;If you're running a single app, that's genuinely the recommended way to set up Directus on a Coolify-managed VPS. Going in, I assumed there were several good reasons to split the services apart instead — more control over backups, monitoring, restarts, that kind of thing. So before recommending decoupling, I actually tested each of those assumptions on a live Coolify instance.&lt;/p&gt;

&lt;p&gt;Most of them turned out to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Myth 1: Restarting Directus Restarts the Whole Stack
&lt;/h2&gt;

&lt;p&gt;I expected that restarting Directus inside the bundled Compose file would restart Redis and Postgres along with it. It doesn't. Coolify lets you restart each service in the stack independently — Directus, Database, and Cache each get their own &lt;strong&gt;Restart&lt;/strong&gt; button, right there in the same view. No decoupling needed for this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Myth 2: You Need a Separate Database Resource for S3 Backups
&lt;/h2&gt;

&lt;p&gt;Same story. Even with Postgres bundled inside the Directus Compose file, Coolify still gives it its own dedicated &lt;strong&gt;Backups&lt;/strong&gt; option, S3 included. This isn't a separate-resource-only feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Myth 3: Scheduled Tasks Require Separate Services
&lt;/h2&gt;

&lt;p&gt;Also not true. Coolify exposes a &lt;strong&gt;Scheduled Tasks&lt;/strong&gt; tab per service, even inside a single bundled stack — complete with a &lt;strong&gt;Container name&lt;/strong&gt; dropdown letting you target the cron job at just the database, or just Directus, without splitting anything apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Holds Up
&lt;/h2&gt;

&lt;p&gt;Two things survived testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: metrics.&lt;/strong&gt; This one's confirmed directly in Coolify's own documentation — CPU and memory metrics collection is explicitly not available for Docker Compose–based deployments. If you want to see per-container resource usage through Coolify's built-in monitoring, the service needs to be created as its own standalone resource, not bundled inside a Compose file. This is a real, documented limitation of the bundled approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: sharing a database across multiple apps.&lt;/strong&gt; This one isn't a Coolify feature at all — it's just how Docker networking works. A database defined inside one app's Compose file lives on that stack's own private network by default. A second, completely separate application can't reach it without deliberately bridging the two networks. If you've got a desktop app and a mobile app that both need to talk to the same Postgres instance, that database needs to exist as its own standalone resource from the start — it can't stay tucked inside one app's Compose file.&lt;/p&gt;

&lt;p&gt;So this really comes down to two reasons to decouple, not five — one a genuine Coolify limitation, the other a structural fact about Docker networking. Let's set both of those up properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the Resources
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Adding the PostgreSQL Database
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dashboard → Add Project&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;+ Add Resource&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Databases → PostgreSQL → Supabase PostgreSQL (with extensions)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Change the name to something human-friendly&lt;/li&gt;
&lt;li&gt;Copy your username and password and save them somewhere — you'll need them shortly&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Start&lt;/strong&gt;, and wait for the database to spin up (this can take a little while)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once it's up, the status should read &lt;strong&gt;"Running (Healthy)."&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Enable metrics while you're here.&lt;/strong&gt; In the sidebar, go to &lt;strong&gt;Servers → localhost → Metrics&lt;/strong&gt;, and enable metrics. Back in your project, under &lt;strong&gt;Databases&lt;/strong&gt;, click your Postgres database, then &lt;strong&gt;Metrics&lt;/strong&gt; — you should now see live CPU/memory usage for it. This is the exact capability that isn't available on a bundled Compose deployment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Adding Redis Cache
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard → + Add Resource&lt;/strong&gt; (from the project itself)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Databases → Redis&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Rename it to something more convenient&lt;/li&gt;
&lt;li&gt;Copy the Redis connection URL — you'll need it shortly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save&lt;/strong&gt;, then &lt;strong&gt;Start&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once both are running, SSH into your VPS and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to confirm both containers are up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Docker Compose Config for Directus
&lt;/h3&gt;

&lt;p&gt;This is what goes into the empty Compose file for the Directus resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;directus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;directus/directus:12.2.0'&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8055:8055'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./uploads:/directus/uploads'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./extensions:/directus/extensions'&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CMD-SHELL&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--spider&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8055/server/ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secretstring&lt;/span&gt;
      &lt;span class="na"&gt;MARKETPLACE_TRUST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
      &lt;span class="na"&gt;DB_CLIENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pg&lt;/span&gt;
      &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5432'&lt;/span&gt;
      &lt;span class="na"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;CACHE_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;CACHE_STORE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
      &lt;span class="na"&gt;CACHE_AUTO_PURGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;REDIS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ADMIN_EMAIL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;joepublic@example.com&lt;/span&gt;
      &lt;span class="na"&gt;ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1234567890'&lt;/span&gt;
      &lt;span class="na"&gt;CORS_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;CORS_ORIGIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;CORS_CREDENTIALS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;PUBLIC_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Notice the healthcheck already uses &lt;code&gt;127.0.0.1&lt;/code&gt; instead of &lt;code&gt;localhost&lt;/code&gt; — that's the fix from Part 1. Carrying it forward here saves you from hitting the exact same "unhealthy" bug all over again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Adding Directus
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard → + Add Resource&lt;/strong&gt; (from the project itself)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Applications → Docker Compose Empty&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Paste in the Compose configuration above&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network → Connect To Predefined Network&lt;/strong&gt; → check the box&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Services → Directus service → Settings&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Add your Directus subdomain — remember to use &lt;code&gt;https://&lt;/code&gt; (e.g. &lt;code&gt;https://directus.yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Connecting the Services Together
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A long, unguessable random string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The name of your Postgres container — run &lt;code&gt;docker ps&lt;/code&gt; on your VPS to find it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_DATABASE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The name of the database on your Postgres server (Coolify's default is usually &lt;code&gt;postgres&lt;/code&gt;, but confirm it against your Postgres service)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The database username, from the Postgres service you created earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The password from that same Postgres service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REDIS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Redis connection URL from the Redis service you created earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Directus subdomain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Redis URL gotcha:&lt;/strong&gt; the connection URL follows the format &lt;code&gt;redis://username:password@host:port&lt;/code&gt;. Coolify's generated URL includes the username you set when creating the Redis resource — in my case, that username was also &lt;code&gt;redis&lt;/code&gt;, so the URL looked like &lt;code&gt;redis://redis:somelongvariable...&lt;/code&gt;. Directus doesn't need the username here, just the password, so strip that segment out: &lt;code&gt;redis://:somelongvariable...&lt;/code&gt;. If you used a different username when creating your Redis resource, remove &lt;em&gt;that&lt;/em&gt; value instead — not literally the word "redis."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Setting Up Environment Variables
&lt;/h2&gt;

&lt;p&gt;Rather than hardcoding any of this directly into the Compose file, move it into Directus's environment variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dashboard → Projects → Services / Directus&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment Variables&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;+ Add&lt;/strong&gt;, enter the variable name in all caps, and its value&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Repeat for: &lt;code&gt;SECRET&lt;/code&gt;, &lt;code&gt;DB_HOST&lt;/code&gt;, &lt;code&gt;DB_DATABASE&lt;/code&gt;, &lt;code&gt;DB_USER&lt;/code&gt;, &lt;code&gt;DB_PASSWORD&lt;/code&gt;, &lt;code&gt;ADMIN_EMAIL&lt;/code&gt;, &lt;code&gt;ADMIN_PASSWORD&lt;/code&gt;, and &lt;code&gt;REDIS&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Updating the Compose File
&lt;/h3&gt;

&lt;p&gt;Now reference those variables instead of the raw values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${SECRET}'&lt;/span&gt;
&lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${DB_HOST}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...and so on for each variable. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Restart&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Launching Directus
&lt;/h2&gt;

&lt;p&gt;Paste your Directus subdomain into the browser, and you should land on your running Directus instance. You can also click &lt;strong&gt;Links&lt;/strong&gt; on the Directus service, then click the subdomain — it'll take you straight to your Directus Studio login.&lt;/p&gt;

&lt;p&gt;Same as the last video: sign up for your free license, which arrives by email, and paste it into your Directus instance to unlock everything.&lt;/p&gt;




&lt;p&gt;Any questions or hit a different result testing any of these yourself? Drop it in the comments — I'm genuinely curious whether this holds up across different Coolify versions and setups.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>coolify</category>
      <category>postgres</category>
      <category>redis</category>
    </item>
    <item>
      <title>Setting Up Directus on a Coolify VPS (And Fixing the "Unhealthy" Error)</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Fri, 31 Jul 2026 06:56:09 +0000</pubDate>
      <link>https://dev.to/wadethomastt/setting-up-directus-on-a-coolify-vps-and-fixing-the-unhealthy-error-eaa</link>
      <guid>https://dev.to/wadethomastt/setting-up-directus-on-a-coolify-vps-and-fixing-the-unhealthy-error-eaa</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/OMX1Rk1HTZo"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&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 "Data Engine" — alongside a web application that lets both developers and non-developers manipulate data and assets through a no-code interface called the "Data Studio."&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;h2&gt;
  
  
  Let's Get Started
&lt;/h2&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;h3&gt;
  
  
  1. Log in to your Coolify Dashboard
&lt;/h3&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;h3&gt;
  
  
  2. Add a Resource
&lt;/h3&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 href="https://directus.com" rel="noopener noreferrer"&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;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;directus/directus:12.2.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&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;h3&gt;
  
  
  3. Configure the Service
&lt;/h3&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;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;PUBLIC_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://directus.yourdomain.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&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;h3&gt;
  
  
  4. Launch Directus
&lt;/h3&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;h2&gt;
  
  
  The Errors
&lt;/h2&gt;

&lt;p&gt;At this point, your browser will show two errors: a &lt;strong&gt;"Not Secure"&lt;/strong&gt; warning in the address bar, and a &lt;strong&gt;"no available server"&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;h2&gt;
  
  
  Diagnosing the Problem
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{json .State.Health}}'&lt;/span&gt; &amp;lt;your-directus-container-name&amp;gt; | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unhealthy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"FailingStreak"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;79&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Log"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-30T18:12:21.445964902-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"End"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-30T18:12:21.502447668-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ExitCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wget: can't connect to remote host: Connection refused&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-30T18:12:31.505411263-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"End"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-30T18:12:31.556307544-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ExitCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wget: can't connect to remote host: Connection refused&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first clue: &lt;code&gt;"wget: can't connect to remote host: Connection refused"&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;h2&gt;
  
  
  Inspecting the Docker Compose File
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;directus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;directus/directus:12.2.0'&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8055:8055'&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./uploads:/directus/uploads'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./extensions:/directus/extensions'&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CMD-SHELL&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--spider&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:8055/server/ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;start_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h3&gt;
  
  
  Why &lt;code&gt;localhost&lt;/code&gt; Breaks This
&lt;/h3&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;   &lt;span class="n"&gt;localhost&lt;/span&gt;   &lt;span class="c"&gt;# IPv4
&lt;/span&gt;::&lt;span class="m"&gt;1&lt;/span&gt;         &lt;span class="n"&gt;localhost&lt;/span&gt;   &lt;span class="c"&gt;# IPv6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs &amp;lt;your-directus-container-name&amp;gt; &lt;span class="nt"&gt;--tail&lt;/span&gt; 150
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Directus only bound to IPv4. The startup log says exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server started at http://0.0.0.0:8055
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.0.0.0&lt;/code&gt; means "listen on every IPv4 interface" — 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;"Connection refused"&lt;/strong&gt; rather than a slow timeout. A timeout would mean something was reachable but not responding; a refusal means the OS said "nothing's here" 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;h2&gt;
  
  
  The Fix
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;directus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;directus/directus:12.2.0'&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8055:8055'&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./uploads:/directus/uploads'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./extensions:/directus/extensions'&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CMD-SHELL&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--spider&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8055/server/ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;start_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 Links, then your Directus subdomain, and you should land on the Data Studio login screen. If you get a "Not Secure" error in the browser, this is usually just a stale cache from visiting the subdomain before the fix. Try a hard refresh first (Ctrl+Shift+R). If that doesn't clear it, clear your browser's cache entirely (all time, not just a recent window) — a partial or time-bounded clear may not be enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logging In &amp;amp; Licensing
&lt;/h2&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 href="https://directus.com/oig" rel="noopener noreferrer"&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;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>cloud</category>
      <category>devops</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Coolify: The Complete Manual Setup Guide (For When the Auto-Install Script Won't Cut It)</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Thu, 23 Jul 2026 03:43:43 +0000</pubDate>
      <link>https://dev.to/wadethomastt/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-wont-cut-it-4epm</link>
      <guid>https://dev.to/wadethomastt/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-wont-cut-it-4epm</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/jlUzYm6W-bI"&gt;
  &lt;/iframe&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;h2&gt;
  
  
  Minimum Hardware Requirements
&lt;/h2&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;h2&gt;
  
  
  Prerequisites
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'/home/your-path/.ssh/known_hosts'&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s1"&gt;'your-vps-ip'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Installing SSH
&lt;/h3&gt;

&lt;p&gt;If you followed the earlier videos in this series, OpenSSH is already installed. If not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; openssh-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status ssh
&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Installing CURL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; curl
curl &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  Running the First Ansible Playbook
&lt;/h2&gt;

&lt;p&gt;Connect Ansible to the VPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ANSIBLE_HOST_KEY_CHECKING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FALSE ansible &lt;span class="nt"&gt;-i&lt;/span&gt; ./inventory/hosts vpsDemo &lt;span class="nt"&gt;-m&lt;/span&gt; ping &lt;span class="nt"&gt;--user&lt;/span&gt; root &lt;span class="nt"&gt;--ask-pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the update playbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-playbook ./playbooks/apt-update.yml &lt;span class="nt"&gt;--user&lt;/span&gt; root &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"ansible_port=22"&lt;/span&gt; &lt;span class="nt"&gt;--ask-pass&lt;/span&gt; &lt;span class="nt"&gt;--ask-become-pass&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ./inventory/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  Installing Docker Engine
&lt;/h2&gt;

&lt;p&gt;Remove any conflicting packages first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt remove &lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--get-selections&lt;/span&gt; docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add Docker's official GPG key and repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ca-certificates curl
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/ubuntu/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;a+r /etc/apt/keyrings/docker.asc

&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.sources &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UBUNTU_CODENAME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
Components: stable
Architectures: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
Signed-By: /etc/apt/keyrings/docker.asc
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating a Non-Root Admin User
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-playbook ./playbooks/basic-secure.yml &lt;span class="nt"&gt;--user&lt;/span&gt; root &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"ansible_port=22"&lt;/span&gt; &lt;span class="nt"&gt;--ask-pass&lt;/span&gt; &lt;span class="nt"&gt;--ask-become-pass&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ./inventory/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set up that user's SSH directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/your-sudo-user/.ssh
&lt;span class="nb"&gt;sudo touch&lt;/span&gt; /home/your-sudo-user/.ssh/authorized_keys
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;700 /home/your-sudo-user/.ssh
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /home/your-sudo-user/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting Up Coolify's Directory Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /data/coolify/&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt;,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /data/coolify/ssh/&lt;span class="o"&gt;{&lt;/span&gt;keys,mux&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /data/coolify/proxy/dynamic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Generating and Adding an SSH Key
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ssh-keygen &lt;span class="nt"&gt;-f&lt;/span&gt; /data/coolify/ssh/keys/id.your-sudo-user@host.docker.internal &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; your-sudo-user@coolify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the public key to the authorized_keys file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /data/coolify/ssh/keys/id.your-sudo-user@host.docker.internal.pub | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /home/your-sudo-user/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pulling Coolify's Configuration Files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://cdn.coollabs.io/coolify/docker-compose.yml &lt;span class="nt"&gt;-o&lt;/span&gt; /data/coolify/source/docker-compose.yml
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://cdn.coollabs.io/coolify/docker-compose.prod.yml &lt;span class="nt"&gt;-o&lt;/span&gt; /data/coolify/source/docker-compose.prod.yml
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://cdn.coollabs.io/coolify/.env.production &lt;span class="nt"&gt;-o&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://cdn.coollabs.io/coolify/upgrade.sh &lt;span class="nt"&gt;-o&lt;/span&gt; /data/coolify/source/upgrade.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Generating Secure Environment Values
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|APP_ID=.*|APP_ID=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 16&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|APP_KEY=.*|APP_KEY=base64:&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|DB_PASSWORD=.*|DB_PASSWORD=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|REDIS_PASSWORD=.*|REDIS_PASSWORD=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|PUSHER_APP_ID=.*|PUSHER_APP_ID=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|PUSHER_APP_KEY=.*|PUSHER_APP_KEY=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s|PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; /data/coolify/source/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Permissions and Docker Setup
&lt;/h2&gt;

&lt;p&gt;Set correct ownership and permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 9999:root /data/coolify
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /data/coolify &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;755 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /data/coolify &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;644 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; your-sudo-user:your-sudo-user /home/your-sudo-user/.ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the Docker network Coolify expects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker network create &lt;span class="nt"&gt;--attachable&lt;/span&gt; coolify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your user to the Docker group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker your-sudo-user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Starting Coolify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; /data/coolify/source/.env &lt;span class="nt"&gt;-f&lt;/span&gt; /data/coolify/source/docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; /data/coolify/source/docker-compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--pull&lt;/span&gt; always &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm it's running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then visit &lt;code&gt;http://YOUR-SERVER-IP:8000&lt;/code&gt; in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup your SSL Certificates
&lt;/h2&gt;

&lt;p&gt;Coolify's reverse-proxy Traefik does this under the hood automatically.&lt;br&gt;
In your DNS records add two A records.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;A     @     your-vps-ip-address        14400
A     &lt;span class="k"&gt;*&lt;/span&gt;     your-vps-ip-address        14400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In you Coolify dashboard go to settings in the left side column. In the input box marked URL type your https Subdomain for Coolify there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;https://coolify.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik will automatically apply a Let's Encrypt certificate to your Coolify subdomain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disable port 8000
&lt;/h2&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 allows the ports you want and block everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-Up
&lt;/h2&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>coolify</category>
      <category>docker</category>
      <category>ansible</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>The Ansible Playbook that will Harden Your VPS</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Tue, 14 Jul 2026 20:44:28 +0000</pubDate>
      <link>https://dev.to/wadethomastt/the-ansible-playbook-that-will-harden-your-vps-in-seconds-49ca</link>
      <guid>https://dev.to/wadethomastt/the-ansible-playbook-that-will-harden-your-vps-in-seconds-49ca</guid>
      <description>&lt;h1&gt;
  
  
  Ansible Playbooks — Automate VPS Hardening, User Creation &amp;amp; Removal
&lt;/h1&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/vl8IW8F1mxA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&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 href="https://docs.ansible.com" rel="noopener noreferrer"&gt;Ansible Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.ansible.com/ansible/latest/collections/index.html" rel="noopener noreferrer"&gt;Ansible Collection Index&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Basics
&lt;/h2&gt;

&lt;p&gt;A task is broken up into four parts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&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;/div&gt;




&lt;h2&gt;
  
  
  Playbook 1 — basic-secure.yml
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Harden Ubuntu VPS Security Configuration&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vpsDemo&lt;/span&gt;
  &lt;span class="na"&gt;gather_facts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;vars_prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;custom_admin_user'&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Enter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;administrator&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account'&lt;/span&gt;
      &lt;span class="na"&gt;private&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. GENERATE RANDOM PASSWORD&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate random password&lt;/span&gt;
      &lt;span class="na"&gt;set_fact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;new_admin_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('password',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'/dev/null&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chars=ascii_letters,digits,hexdigits&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;length=16')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. CREATE SUDO USER&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure the custom admin user exists&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new_admin_password&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;password_hash('sha512')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sudo&lt;/span&gt;
        &lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow the admin user to use sudo without a password prompt&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ALL=(ALL)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;NOPASSWD:ALL&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/etc/sudoers.d/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0440'&lt;/span&gt;
        &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/sbin/visudo -cf %s&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. CONFIGURE UFW FIREWALL&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reset UFW to default settings&lt;/span&gt;
      &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reset&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set UFW default policies to deny incoming&lt;/span&gt;
      &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;
        &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;incoming&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Open Port 80 (HTTP)&lt;/span&gt;
      &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;80'&lt;/span&gt;
        &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Open Port 443 (HTTPS)&lt;/span&gt;
      &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;443'&lt;/span&gt;
        &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Open Custom SSH Port &lt;/span&gt;&lt;span class="m"&gt;2222&lt;/span&gt;
      &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2222'&lt;/span&gt;
        &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enable UFW Firewall&lt;/span&gt;
      &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;enabled&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. INSTALL FAIL2BAN&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Fail2Ban&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.apt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fail2ban&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;update_cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure Fail2Ban is running and enabled on boot&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fail2ban&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;started&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="c1"&gt;# 5. HARDEN SSH&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure SSH to use custom port &lt;/span&gt;&lt;span class="m"&gt;2222&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.lineinfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/ssh/sshd_config&lt;/span&gt;
        &lt;span class="na"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?Port\s'&lt;/span&gt;
        &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Port&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2222'&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Disable Root SSH Login&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.lineinfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/ssh/sshd_config&lt;/span&gt;
        &lt;span class="na"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?PermitRootLogin\s'&lt;/span&gt;
        &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PermitRootLogin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no'&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;

    &lt;span class="c1"&gt;# 6. FIX SYSTEMD SSH SOCKET&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create systemd override directory for SSH socket&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/systemd/system/ssh.socket.d&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;directory&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0755'&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Write dual-stack IPv4/IPv6 socket configuration&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/systemd/system/ssh.socket.d/listen.conf&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0644'&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;[Socket]&lt;/span&gt;
          &lt;span class="s"&gt;ListenStream=&lt;/span&gt;
          &lt;span class="s"&gt;ListenStream=0.0.0.0:2222&lt;/span&gt;
          &lt;span class="s"&gt;ListenStream=[::]:2222&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reload systemd daemon&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.systemd_service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;daemon_reload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart SSH socket&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh.socket&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restarted&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart SSH service&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restarted&lt;/span&gt;

    &lt;span class="c1"&gt;# 7. DISPLAY CREDENTIALS&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Display your new credentials (SAVE THESE IMMEDIATELY)&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;========================================================'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NEW&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SUDO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;USERNAME:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NEW&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PASSWORD:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new_admin_password&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CUSTOM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SSH&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PORT:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2222'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;========================================================'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  Playbook 2 — add-vps-user.yml
&lt;/h2&gt;

&lt;p&gt;Creates a new sudo user with a randomly generated secure password and prints the credentials to your screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Universal Semi-Automated User Creation Script&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vpsDemo&lt;/span&gt;
  &lt;span class="na"&gt;gather_facts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;vars_prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;custom_admin_user'&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Enter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;administrator&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account'&lt;/span&gt;
      &lt;span class="na"&gt;private&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate random secure password&lt;/span&gt;
      &lt;span class="na"&gt;set_fact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;new_random_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('password',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'/dev/null&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chars=ascii_letters,digits&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;length=16')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure the new user account exists&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new_random_password&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;password_hash('sha512')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sudo&lt;/span&gt;
        &lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow the new user to use sudo without a password prompt&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ALL=(ALL)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;NOPASSWD:ALL&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/etc/sudoers.d/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0440'&lt;/span&gt;
        &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/sbin/visudo -cf %s&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Display New User Credentials&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;========================================================'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NEW&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ADMINISTRATIVE&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ACCOUNT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CREATED&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SUCCESSFULLY!'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USERNAME:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;custom_admin_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PASSWORD:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new_random_password&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;========================================================'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Playbook 3 — remove-vps-user.yml
&lt;/h2&gt;

&lt;p&gt;Completely purges a user account, their home directory, mail spool, and sudo privileges from the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Universal User and Privilege Removal Script&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
  &lt;span class="na"&gt;gather_facts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;vars_prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user_to_delete'&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Enter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exact&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;you&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;want&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;COMPLETELY&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;delete'&lt;/span&gt;
      &lt;span class="na"&gt;private&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Delete the user's custom sudoers configuration file&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/etc/sudoers.d/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user_to_delete&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;absent&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Remove the user account and purge their files&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user_to_delete&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;absent&lt;/span&gt;
        &lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# deletes home directory and mail spool&lt;/span&gt;
        &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;    &lt;span class="c1"&gt;# kills any active processes owned by the user&lt;/span&gt;
        &lt;span class="na"&gt;ignore_errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Display Removal Confirmation&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;========================================================'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUCCESS:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Account&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user_to_delete&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;their&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sudo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;privileges"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;completely&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;purged&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;server.'&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;========================================================'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&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;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 href="https://northernrangedigital.lemonsqueezy.com/" rel="noopener noreferrer"&gt;northernrangedigital.lemonsqueezy.com&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;🌐 &lt;a href="https://northernrangedigital.com" rel="noopener noreferrer"&gt;northernrangedigital.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ansible</category>
      <category>ansibleplaybooks</category>
      <category>codeautomation</category>
      <category>devops</category>
    </item>
    <item>
      <title>Ansible Installation and Configuration on Ubuntu — Automate Your Server Management</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Fri, 10 Jul 2026 04:17:07 +0000</pubDate>
      <link>https://dev.to/wadethomastt/ansible-installation-and-configuration-on-ubuntu-automate-your-server-management-1fdg</link>
      <guid>https://dev.to/wadethomastt/ansible-installation-and-configuration-on-ubuntu-automate-your-server-management-1fdg</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/1U8ID8j_gLg"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is Ansible?
&lt;/h3&gt;

&lt;p&gt;Ansible is an automation language that can describe any IT environment, whether homelab or large-scale infrastructure. It is easy to learn and reads like clear documentation.&lt;/p&gt;

&lt;p&gt;If you manage multiple servers and find yourself doing the same configuration over and over — setting up SSH keys, disabling root users, configuring firewalls — Ansible can automate the entire process and dramatically increase your productivity.&lt;/p&gt;

&lt;p&gt;It only requires Ansible on the &lt;strong&gt;Control Node&lt;/strong&gt; and &lt;strong&gt;Python 3&lt;/strong&gt; on the &lt;strong&gt;Managed Node&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Control Node?
&lt;/h3&gt;

&lt;p&gt;The system that Ansible is installed on — it controls the remote machines.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Managed Node?
&lt;/h3&gt;

&lt;p&gt;The remote system or host that Ansible controls. Ansible is &lt;strong&gt;agentless&lt;/strong&gt;, meaning you don't need to install Ansible on managed nodes — just Python 3.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installing Ansible on Ubuntu (Control Node)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;software-properties-common
&lt;span class="nb"&gt;sudo &lt;/span&gt;add-apt-repository &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--update&lt;/span&gt; ppa:ansible/ansible
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ansible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Ensure Python 3 is installed on your remote server. Ubuntu 24.04 LTS ships with Python 3 by default.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Create the Inventory Folder and Hosts File
&lt;/h2&gt;

&lt;p&gt;The hosts file maps the remote machines you want to control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Folder structure:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ansible/&lt;br&gt;
├── inventory/&lt;br&gt;
│   └── hosts&lt;br&gt;
└── playbooks/&lt;br&gt;
└── apt-update.yml&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;inventory/hosts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[servers]&lt;/span&gt;
&lt;span class="err"&gt;vpsServer&lt;/span&gt; &lt;span class="py"&gt;ansible_host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.10.100.45&lt;/span&gt;
&lt;span class="err"&gt;work-ToRule&lt;/span&gt;
&lt;span class="err"&gt;10.10.45.62&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can give hosts an alias by pairing a name with an IP address. In the example above, &lt;code&gt;vpsServer&lt;/code&gt; is an alias for &lt;code&gt;10.10.100.45&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your First Playbook — Update Ubuntu and Set Timezone
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;playbooks/apt-update.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;serial&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set system timezone to Trinidad and Tobago time&lt;/span&gt;
      &lt;span class="na"&gt;community.general.timezone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;America/Port_of_Spain&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update apt cache&lt;/span&gt;
      &lt;span class="na"&gt;apt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;update_cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
        &lt;span class="na"&gt;cache_valid_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upgrade all packages to the latest version&lt;/span&gt;
      &lt;span class="na"&gt;apt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;upgrade&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dist&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check if reboot is required&lt;/span&gt;
      &lt;span class="na"&gt;stat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/run/reboot-required&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reboot_required_file&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reboot the server&lt;/span&gt;
      &lt;span class="na"&gt;reboot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Reboot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;initiated&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ansible&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;due&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;package&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;upgrades'&lt;/span&gt;
        &lt;span class="na"&gt;connect_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
        &lt;span class="na"&gt;reboot_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
        &lt;span class="na"&gt;pre_reboot_delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;post_reboot_delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reboot_required_file.stat.exists&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Breaking Down the Playbook
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&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;code&gt;hosts: '*'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Target all hosts in inventory. Use an alias, DNS name, or IP to target a single host.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;become: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Grants Ansible sudo privileges.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serial: 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Processes servers one at a time instead of all at once.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A list of individual actions to run on the target hosts.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each task has four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;name&lt;/code&gt;&lt;/strong&gt; — a plain-text description of what the task does&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collection&lt;/strong&gt; (&lt;code&gt;community.general&lt;/code&gt;) — the Ansible content bundle the module belongs to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module&lt;/strong&gt; (&lt;code&gt;timezone&lt;/code&gt;, &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;reboot&lt;/code&gt;) — the tool that executes the action&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameters&lt;/strong&gt; (&lt;code&gt;name: America/Port_of_Spain&lt;/code&gt;) — the specific options passed to the module&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;📚 Browse all available modules and collections at &lt;a href="https://docs.ansible.com/projects/ansible/latest/collections/index.html" rel="noopener noreferrer"&gt;docs.ansible.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Running the Playbook
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Test connectivity with a ping
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ANSIBLE_HOST_KEY_CHECKING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FALSE ansible &lt;span class="nt"&gt;-i&lt;/span&gt; ./inventory/hosts vpsServer &lt;span class="nt"&gt;-m&lt;/span&gt; ping &lt;span class="nt"&gt;--user&lt;/span&gt; root &lt;span class="nt"&gt;--ask-pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&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;code&gt;ANSIBLE_HOST_KEY_CHECKING=FALSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skips SSH host key verification — useful for fresh servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-i ./inventory/hosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Points to your inventory file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vpsServer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The target host alias&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-m ping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs the ping module to check connectivity and Python availability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ask-pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prompts for SSH password&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once you get a green &lt;strong&gt;pong&lt;/strong&gt; response, you're ready to run the playbook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Run the playbook
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-playbook ./playbooks/apt-update.yml &lt;span class="nt"&gt;--user&lt;/span&gt; root &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"ansible_port=22"&lt;/span&gt; &lt;span class="nt"&gt;--ask-pass&lt;/span&gt; &lt;span class="nt"&gt;--ask-become-pass&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ./inventory/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&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;code&gt;ansible-playbook&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs a full automation script instead of a single ad-hoc task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;./playbooks/apt-update.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Path to your playbook file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--user root&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSH connection username&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-e "ansible_port=22"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Injects extra variable to force port 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ask-pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prompts for SSH login password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ask-become-pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prompts for sudo password (redundant when logging in as root)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-i ./inventory/hosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Points to your inventory file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Execution Flow
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Ansible reads &lt;code&gt;./inventory/hosts&lt;/code&gt; to find the target server's IP&lt;/li&gt;
&lt;li&gt;Prompts for SSH password&lt;/li&gt;
&lt;li&gt;Prompts for sudo password&lt;/li&gt;
&lt;li&gt;Connects to port 22 as root&lt;/li&gt;
&lt;li&gt;Opens &lt;code&gt;apt-update.yml&lt;/code&gt; and executes each task in order&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A big shout-out to Aldo &lt;a href="https://dev.to/aldo_cve"&gt;@aldo_cve&lt;/a&gt; for recommending Ansible in a previous post — it's been a great addition to my server management workflow.&lt;/p&gt;

&lt;p&gt;I hope you found this walkthrough useful. Stay tuned for more posts where I share playbooks I find useful in my day-to-day infrastructure work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Looking for developer templates built on TanStack, Directus, and Tailwind CSS? Check out my store 👇&lt;/em&gt;&lt;br&gt;
&lt;em&gt;🛒 &lt;a href="https://northernrangedigital.lemonsqueezy.com/" rel="noopener noreferrer"&gt;northernrangedigital.lemonsqueezy.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ansible</category>
      <category>devops</category>
      <category>ubuntu</category>
      <category>linux</category>
    </item>
    <item>
      <title>How to Secure a VPS: The Complete Ubuntu Hardening Guide</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Thu, 02 Jul 2026 05:26:20 +0000</pubDate>
      <link>https://dev.to/wadethomastt/how-to-secure-a-vps-the-complete-ubuntu-hardening-guide-40ee</link>
      <guid>https://dev.to/wadethomastt/how-to-secure-a-vps-the-complete-ubuntu-hardening-guide-40ee</guid>
      <description>&lt;p&gt;The moment a VPS gets a public IP, it's already being scanned. Automated bots start hammering the default root account with password guesses within minutes of the server going online — before you've even finished your first &lt;code&gt;apt update&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide walks through the baseline hardening I run on every fresh Ubuntu server before deploying anything to it: creating a proper user account, locking down root, setting up a firewall, quieting bot noise on SSH, and auto-banning repeat offenders.&lt;/p&gt;

&lt;p&gt;Prefer to follow along on video?&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bv9OtbRqLMo"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Stop Using the Root User
&lt;/h2&gt;

&lt;p&gt;Every Linux server in the world ships with an account named &lt;code&gt;root&lt;/code&gt;. That's the problem — hackers already know the username, so they only need to guess the password or find one exploit to get full control. A custom username forces an attacker to guess two unknowns instead of one.&lt;/p&gt;

&lt;p&gt;A few other reasons to move off root:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No safety net.&lt;/strong&gt; Root executes destructive commands instantly, no confirmation. Run &lt;code&gt;rm -rf /&lt;/code&gt; as root and it's gone. A standard user needs &lt;code&gt;sudo&lt;/code&gt;, which at least forces a pause and a password prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bots target root specifically.&lt;/strong&gt; The moment a VPS goes online, automated bots start brute-forcing the root account with thousands of password guesses per minute. Disable root login and that entire attack surface disappears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No accountability.&lt;/strong&gt; If more than one person has server access, a shared root login makes it impossible to tell who did what. Individual sudo accounts get logged to &lt;code&gt;/var/log/auth.log&lt;/code&gt;, so every command is tied to a specific user.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Create a sudo user
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adduser your_username
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;your_username
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-aG&lt;/code&gt; breaks down into two flags: &lt;code&gt;-G&lt;/code&gt; adds the user to the group that follows (&lt;code&gt;sudo&lt;/code&gt;), and &lt;code&gt;-a&lt;/code&gt; (append) makes sure the user is &lt;em&gt;added&lt;/em&gt; to that group rather than having all their other group memberships wiped out. Together, &lt;code&gt;-aG&lt;/code&gt; says: add this user to &lt;code&gt;sudo&lt;/code&gt;, keep everything else as-is.&lt;/p&gt;

&lt;p&gt;Verify it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;groups &lt;/span&gt;your_username
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lock down root
&lt;/h3&gt;

&lt;p&gt;Log out of root and back in as your new sudo user first, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Lock the root password&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd &lt;span class="nt"&gt;-l&lt;/span&gt; root

&lt;span class="c"&gt;# Disable root login over SSH&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/ssh/sshd_config
&lt;span class="c"&gt;# set: PermitRootLogin no&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd

&lt;span class="c"&gt;# Confirm root is locked&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd &lt;span class="nt"&gt;-S&lt;/span&gt; root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Set Up a Firewall (UFW)
&lt;/h2&gt;

&lt;p&gt;A firewall closes off everything you're not explicitly using. On a fresh VPS, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blocking brute-force attempts on ports you don't need exposed&lt;/li&gt;
&lt;li&gt;Keeping internal-only services (databases, admin tools) off the public internet&lt;/li&gt;
&lt;li&gt;Restricting sensitive ports like SSH to specific IPs, if needed&lt;/li&gt;
&lt;li&gt;Dropping unexpected traffic, which softens basic DoS attempts&lt;/li&gt;
&lt;li&gt;Closing the door on any hidden vulnerability in something you're running&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ubuntu ships with UFW (Uncomplicated Firewall), which blocks all incoming traffic by default and only opens what you explicitly allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Allow SSH first so you don't lock yourself out&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="c"&gt;# or: sudo ufw allow 22/tcp&lt;/span&gt;

&lt;span class="c"&gt;# Allow web traffic if you're hosting a site&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow http
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow https

&lt;span class="c"&gt;# Turn it on&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;

&lt;span class="c"&gt;# Check status&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Change the Default SSH Port
&lt;/h2&gt;

&lt;p&gt;Within minutes of going live, bots start hammering port 22 — not targeted attacks, just scripts sweeping the internet for the default SSH port. Moving to something non-standard, like 2222, makes those scanners skip right past you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/ssh/sshd_config
&lt;span class="c"&gt;# uncomment #Port 22 and change it:&lt;/span&gt;
&lt;span class="c"&gt;# Port 2222&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 2222/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth being honest about this one: security researchers call this "security through obscurity," and on its own it's a weak measure — it doesn't make the server harder to break into. What it does do is keep your auth logs from being flooded with bot noise, which makes real suspicious activity much easier to spot. Pair it with SSH keys, disabled password auth, and Fail2Ban for actual hardening.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Install Fail2Ban
&lt;/h2&gt;

&lt;p&gt;Fail2Ban watches your logs for repeated failed login attempts and temporarily bans the offending IP at the firewall level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's worth running:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free, open-source, and quick to set up&lt;/li&gt;
&lt;li&gt;Highly configurable — ban duration, whitelisted IPs, which services to watch&lt;/li&gt;
&lt;li&gt;Bans happen at the firewall, so malicious traffic doesn't eat server resources&lt;/li&gt;
&lt;li&gt;Can integrate with notifications for real-time alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where it falls short:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reactive, not preventive — it only acts after a set number of failed attempts have already happened&lt;/li&gt;
&lt;li&gt;Can lock out legitimate users who fat-finger a password a few times in a row&lt;/li&gt;
&lt;li&gt;Weak against distributed attacks, since it bans by IP and botnets rotate through thousands of them&lt;/li&gt;
&lt;li&gt;Vulnerable to IP spoofing&lt;/li&gt;
&lt;li&gt;Can conflict with Docker's iptables rules on a Docker host, causing bans to fail or hit the wrong container&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Install it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start fail2ban
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The &lt;code&gt;-y&lt;/code&gt; flag auto-confirms every prompt during install — only use it once you're already certain about the package.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-Up
&lt;/h2&gt;

&lt;p&gt;None of these steps make a server unbreakable on their own — a sudo user, a firewall, an obscure SSH port, and Fail2Ban are each individually beatable. Stacked together, they cut off the low-effort, automated attacks that hit every public IP within minutes of going live, and they keep your logs clean enough that you'd actually notice something that isn't normal.&lt;/p&gt;

&lt;p&gt;If you found this useful, the video above walks through each step live. Let me know in the comments if you run into anything setting this up on your own box.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What is Redis? The In-Memory Data Store That Makes Your App Faster</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Wed, 10 Jun 2026 03:44:49 +0000</pubDate>
      <link>https://dev.to/wadethomastt/what-is-redis-the-in-memory-data-store-that-makes-your-app-faster-291p</link>
      <guid>https://dev.to/wadethomastt/what-is-redis-the-in-memory-data-store-that-makes-your-app-faster-291p</guid>
      <description>&lt;p&gt;🎬 This article is a companion to my YouTube video. Watch it here:&lt;br&gt;
  &lt;iframe src="https://www.youtube.com/embed/e3KNJr1ATv8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In this video we are going to talk about Redis — what it is, what it does, and why it is an important part of my back-end stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Redis?
&lt;/h2&gt;

&lt;p&gt;Redis is a free, open-source, in-memory data store. Unlike PostgreSQL which stores data on disk, Redis stores data entirely in memory — in RAM. This makes it extremely fast. Redis can handle millions of operations per second with sub-millisecond response times.&lt;br&gt;
Redis is most commonly used as a cache, a session store, a message broker, and a real-time data store.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Caching?
&lt;/h2&gt;

&lt;p&gt;When your application queries a database, that query takes time — it reads from disk, processes the query, and returns the result. If the same query is made thousands of times per second, you are hitting the database thousands of times unnecessarily.&lt;br&gt;
Caching solves this by storing the result of a query in memory. The first request hits the database and the result is stored in Redis. Every subsequent request gets the result from Redis — which is in memory and therefore much faster — instead of hitting the database again.&lt;br&gt;
Think of it like a shortcut. Instead of driving the long route to the database every time, you take the shortcut through Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Redis Do?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Caching
&lt;/h3&gt;

&lt;p&gt;Store frequently accessed data in memory for fast retrieval. Database query results, API responses, computed values — anything that is expensive to compute and accessed frequently is a good candidate for caching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session Storage
&lt;/h3&gt;

&lt;p&gt;Store user session data in Redis instead of the database. Since sessions are read on every request, having them in memory is significantly faster than a database lookup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limiting
&lt;/h3&gt;

&lt;p&gt;Track how many requests a user or IP address has made in a given time window. Redis's atomic increment operations make it perfect for implementing rate limiting.&lt;br&gt;
Message Queues and Pub/Sub&lt;br&gt;
Redis supports publish/subscribe messaging and message queues. Applications can publish messages to a channel and subscribers receive them in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leaderboards and Counters
&lt;/h3&gt;

&lt;p&gt;Redis sorted sets make it trivial to implement leaderboards, counters, and real-time analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Directus Uses Redis
&lt;/h2&gt;

&lt;p&gt;Directus uses Redis for two primary purposes.&lt;br&gt;
First, as a cache layer. Directus caches API responses, schema information, and permission lookups in Redis. This dramatically reduces database load and speeds up API response times.&lt;br&gt;
Second, for synchronization across multiple Directus instances. If you run multiple instances of Directus for high availability or horizontal scaling, Redis acts as the shared cache and message bus that keeps them in sync.&lt;br&gt;
For a single Directus instance Redis is optional but recommended. For multiple instances it is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Chose Redis
&lt;/h2&gt;

&lt;p&gt;Redis is the industry standard for caching and session storage. It is fast, reliable, widely supported, and Directus has first-class support for it. Adding Redis to the stack costs very little in terms of resources but provides significant performance benefits as the application scales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Redis is a powerful in-memory data store that makes your application faster and more scalable by caching frequently accessed data and handling real-time workloads. It is a small but important piece of a production-ready back-end stack.&lt;br&gt;
In an upcoming video we will deploy Redis alongside Directus and PostgreSQL on our VPS using Coolify.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Redis Website&lt;/li&gt;
&lt;li&gt;Redis Documentation&lt;/li&gt;
&lt;li&gt;Redis GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🔔 Subscribe to my YouTube channel for the full series on building a modern web app back end from scratch.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>redis</category>
      <category>caching</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
