<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The Real Stack]]></title><description><![CDATA[This blog is about the stack that I use which is TanStack Start, Directus, Tailwindcss v4, Shadcn, Zustand and Coolify.]]></description><link>https://blog.northernrangedigital.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>The Real Stack</title><link>https://blog.northernrangedigital.com</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 14 Sep 2026 12:34:09 GMT</lastBuildDate><atom:link href="https://blog.northernrangedigital.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[TanStack Start Basics Part 2 — Connecting to Your Directus Backend]]></title><description><![CDATA[https://www.youtube.com/watch?v=BUULSpRQiAY

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, usi]]></description><link>https://blog.northernrangedigital.com/tanstack-start-basics-part-2-connecting-to-your-directus-backend</link><guid isPermaLink="true">https://blog.northernrangedigital.com/tanstack-start-basics-part-2-connecting-to-your-directus-backend</guid><category><![CDATA[directus]]></category><category><![CDATA[tanstack-query]]></category><category><![CDATA[tanstack router]]></category><category><![CDATA[tanstack-start]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[tanstack-loaders]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Thu, 10 Sep 2026 22:17:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/aa91c940-2111-4d10-90e6-ed0bb6e14227.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=BUULSpRQiAY">https://www.youtube.com/watch?v=BUULSpRQiAY</a></p>

<p>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.</p>
<h2>Installing the Directus SDK</h2>
<p>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.</p>
<pre><code class="language-bash">npm install @directus/sdk
</code></pre>
<h2>Creating the Directus Helper</h2>
<p>Inside <code>src/lib</code>, create a new file: <code>directus.ts</code>.</p>
<pre><code class="language-typescript">import { createDirectus, rest } from '@directus/sdk'

export const directus = createDirectus('Your_Directus_Project_URL').with(rest())
</code></pre>
<p><code>createDirectus</code> initializes a connection client between your app and your Directus instance. On its own it starts empty — capabilities get added by chaining <code>.with()</code>. Since we're fetching over standard HTTP REST requests, we pair it with the <code>rest()</code> modifier.</p>
<h2>Defining Our First Type</h2>
<p>Inside <code>src</code>, create <code>types/index.ts</code>:</p>
<pre><code class="language-typescript">export interface Products {
  id: string
  name: string
  brief: string
  description: string
  price: number
  sale: number
  quantity: number
  isOnSale: boolean
  category: string
  image: string
}
</code></pre>
<h2>Writing the Fetch Function</h2>
<p>Back in <code>directus.ts</code>:</p>
<pre><code class="language-typescript">import { createDirectus, rest, readItems } from '@directus/sdk'
import type { Products } from '@/types'

export const directus = createDirectus('Your_Directus_Project_URL').with(rest())

export async function getProducts(): Promise&lt;Products[]&gt; {
  return directus.request(
    readItems('products', {
      fields: [
        'id',
        'name',
        'brief',
        'description',
        'price',
        'sale',
        'quantity',
        'isOnSale',
        'category',
        'image',
      ],
    }),
  )
}
</code></pre>
<p><code>readItems</code> is a built-in SDK helper that generates a request for fetching multiple items from a given collection. At this point, TypeScript throws:</p>
<pre><code class="language-plaintext">Type 'Record&lt;string, any&gt;[]' is not assignable to type 'Products[]'.
</code></pre>
<h2>Fixing the Schema Error</h2>
<p>This happens because the SDK doesn't automatically know your database's actual shape — it needs a <code>Schema</code> type passed into <code>createDirectus</code>:</p>
<pre><code class="language-typescript">type Schema = {
  products: Products[]
}

export const directus = createDirectus&lt;Schema&gt;('Your_Directus_Project_URL').with(rest())
</code></pre>
<p>This declares that a <code>products</code> collection exists and maps to an array of <code>Products</code>. With this in place, the error resolves — and as a bonus, TypeScript now validates and autocompletes the <code>fields</code> array against this type too.</p>
<h2>Fetching the Data — Method 1: TanStack Query</h2>
<p>Install it:</p>
<pre><code class="language-bash">npm i @tanstack/react-query
</code></pre>
<p>Set it up in <code>__root.tsx</code>:</p>
<pre><code class="language-tsx">import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Create a client
const queryClient = new QueryClient()

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang="en"&gt;
      &lt;head&gt;
        &lt;HeadContent /&gt;
      &lt;/head&gt;
      &lt;body&gt;
        &lt;QueryClientProvider client={queryClient}&gt;
          {children}
        &lt;/QueryClientProvider&gt;
        &lt;Scripts /&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}
</code></pre>
<blockquote>
<p><strong>Why does</strong> <code>RootDocument</code> <strong>take a</strong> <code>children</code> <strong>prop, and why wrap it?</strong> <code>__root.tsx</code> wraps your entire application — every route (About, Index, Products) gets passed in as <code>children</code>, rendered inside this HTML shell. Wrapping <code>children</code> with the query provider means every nested route and component now has access to TanStack Query.</p>
</blockquote>
<p>Now build the component, <code>src/components/Products.tsx</code>:</p>
<pre><code class="language-tsx">import { useQuery } from '@tanstack/react-query'

export default function Products() {
  const { data: products, isLoading: loading } = useQuery({
    queryKey: ['product'],
    queryFn: getProducts,
    staleTime: 5 * 60 * 1000,
    gcTime: 15 * 60 * 1000,
    refetchOnWindowFocus: false,
    retry: 2,
    refetchOnMount: true,
  })

  if (loading) {
    return &lt;div className="container mx-auto p-8"&gt;Loading...&lt;/div&gt;
  }

  return (
    &lt;div&gt;
      &lt;div className="container mx-auto p-8"&gt;
        &lt;h1 className="text-4xl font-medium mb-4"&gt;Products&lt;/h1&gt;
        &lt;div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"&gt;
          {products?.map((product) =&gt; (
            &lt;div key={product.id} className="border rounded-lg p-4"&gt;
              &lt;h2 className="text-xl font-bold mb-2"&gt;{product.name}&lt;/h2&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<p>A quick rundown of the query options:</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>staleTime</code></td>
<td>How long fetched data stays "fresh" (5 min) before a refetch is considered</td>
</tr>
<tr>
<td><code>gcTime</code></td>
<td>How long unused data stays cached in memory (15 min) before being cleared</td>
</tr>
<tr>
<td><code>refetchOnWindowFocus</code></td>
<td>Whether switching tabs and back triggers a refetch (off here)</td>
</tr>
<tr>
<td><code>retry</code></td>
<td>Silent retry attempts on failure before giving up (2 here)</td>
</tr>
<tr>
<td><code>refetchOnMount</code></td>
<td>Whether the component checks for stale data and refetches on every mount (on here)</td>
</tr>
</tbody></table>
<p>Wire it into the route:</p>
<pre><code class="language-tsx">import Products from '@/components/Products'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/products')({
  component: RouteComponent,
})

function RouteComponent() {
  return (
    &lt;div className="p-8"&gt;
      &lt;h1 className="font-bold text-5xl text-slate-700"&gt;Products&lt;/h1&gt;
      &lt;Products /&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<p>Visit <code>localhost:3000/products</code> — real product data, pulled live from Directus.</p>
<h2>Fetching the Data — Method 2: Route Loaders</h2>
<p>Loaders work a little differently, and only on routes directly — not on standalone components. Remove the <code>Products</code> component from the route and build it in directly:</p>
<pre><code class="language-tsx">import { createFileRoute } from '@tanstack/react-router'
import { getProducts } from '@/lib/directus'

export const Route = createFileRoute('/products')({
  loader: async () =&gt; {
    const products = await getProducts()
    return products
  },
  component: RouteComponent,
})

function RouteComponent() {
  const products = Route.useLoaderData()

  return (
    &lt;div className="p-8"&gt;
      &lt;h1 className="font-bold text-5xl text-slate-700 mb-4"&gt;Products&lt;/h1&gt;
      &lt;div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"&gt;
        {products?.map((product) =&gt; (
          &lt;div key={product.id} className="border rounded-lg p-4"&gt;
            &lt;h2 className="text-xl font-bold mb-2"&gt;{product.name}&lt;/h2&gt;
          &lt;/div&gt;
        ))}
      &lt;/div&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<p>The loader runs <code>getProducts</code> before the route renders, and <code>Route.useLoaderData()</code> 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.</p>
<h2>Which One Should You Use?</h2>
<p>Both approaches call the exact same <code>getProducts</code> function underneath — the difference is <em>where</em> the fetching happens and <em>how</em> the result reaches your component.</p>
<p><strong>TanStack Query</strong> 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.</p>
<p><strong>Route loaders</strong> 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.</p>
<h2>Summary</h2>
<p>In this part, we:</p>
<ul>
<li><p>Installed and configured the Directus SDK</p>
</li>
<li><p>Typed our data with a <code>Products</code> interface and a <code>Schema</code></p>
</li>
<li><p>Built our first real fetch function using <code>readItems</code></p>
</li>
<li><p>Rendered a live products grid two different ways — TanStack Query and a route loader</p>
</li>
</ul>
<p>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.</p>
<hr />
<p><em>Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.</em></p>
]]></content:encoded></item><item><title><![CDATA[Setting Up Our Frontend with TanStack Start]]></title><description><![CDATA[We've finished our backend 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 permiss]]></description><link>https://blog.northernrangedigital.com/setting-up-our-frontend-with-tanstack-start</link><guid isPermaLink="true">https://blog.northernrangedigital.com/setting-up-our-frontend-with-tanstack-start</guid><category><![CDATA[Frontend Development]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[tanstack-start]]></category><category><![CDATA[directus]]></category><category><![CDATA[React]]></category><category><![CDATA[hostinger]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 08 Sep 2026 15:53:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/50bd521c-6305-4b70-ba1c-1d20879069ef.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We've finished our backend 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.</p>
<p>Now it's time to build a frontend to actually display that data. For this, we're using <strong>TanStack Start</strong>, <strong>Tailwind CSS</strong>, and <strong>shadcn/ui</strong>. This post covers getting TanStack Start installed and running.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=vzN_njItJFo">https://www.youtube.com/watch?v=vzN_njItJFo</a></p>

<h2>What Is TanStack Start?</h2>
<p>Straight from the <a href="https://tanstack.com/start/latest/docs/framework/react/overview">official docs</a>:</p>
<blockquote>
<p>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.</p>
</blockquote>
<p>In short: it offers the same kind of full-stack capability as frameworks like Next.js or Remix, built on top of TanStack Router.</p>
<h2>Creating the Project</h2>
<p>First, create a folder for your app. I'm naming mine <code>app_fe</code> — "fe" for front end.</p>
<blockquote>
<p><strong>Naming convention tip:</strong> I use <code>&lt;app-name&gt;_fe</code> for all my frontend project folders — so an e-commerce project would be <code>store_fe</code>. Makes the folder's purpose obvious at a glance.</p>
</blockquote>
<p>From inside that folder, run:</p>
<pre><code class="language-bash">npx @tanstack/cli@latest create .
</code></pre>
<blockquote>
<p><strong>Note the trailing dot.</strong> 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.</p>
</blockquote>
<h2>Walking Through the Setup Prompts</h2>
<p>The CLI will walk you through a series of choices:</p>
<table>
<thead>
<tr>
<th>Prompt</th>
<th>Selection</th>
</tr>
</thead>
<tbody><tr>
<td>Select Framework</td>
<td>React</td>
</tr>
<tr>
<td>Select Toolchain</td>
<td>ESLint</td>
</tr>
<tr>
<td>Select Deployment Adapter</td>
<td>None (self-hosting on our own VPS)</td>
</tr>
<tr>
<td>Include demo/example pages?</td>
<td>No</td>
</tr>
<tr>
<td>What add-ons would you like?</td>
<td>shadcn/ui</td>
</tr>
<tr>
<td>Initialize a new git repository?</td>
<td>Yes</td>
</tr>
<tr>
<td>Continue with these settings?</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>Wait for the install to finish.</p>
<h2>Running the App</h2>
<p>Navigate into your app directory (if you aren't already there) and run:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>Open your browser to <code>http://localhost:3000</code>. Congratulations — your TanStack Start app is up and running.</p>
<h2>Editing the Home Page</h2>
<p>Open <code>src/routes/index.tsx</code>. 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.</p>
<p>Add an <code>h1</code> tag inside the empty div:</p>
<pre><code class="language-tsx">&lt;h1&gt;Home&lt;/h1&gt;
</code></pre>
<p>Save, and you'll see "Home" rendered on the page. Now let's add some Tailwind styling:</p>
<pre><code class="language-tsx">&lt;h1 className="font-bold text-5xl text-slate-700"&gt;Home&lt;/h1&gt;
</code></pre>
<p>Save again — notice Tailwind works right out of the box, no extra configuration needed.</p>
<h2>File-Based Routing</h2>
<p>TanStack Start runs on TanStack Router, which uses <strong>file-based routing</strong>. Every file added to the <code>routes</code> folder automatically becomes a navigable page.</p>
<h3>Adding an About Page</h3>
<p>Create <code>src/routes/about.tsx</code>:</p>
<pre><code class="language-tsx">&lt;div className="p-8"&gt;
  &lt;h1 className="font-bold text-5xl text-slate-700"&gt;About&lt;/h1&gt;
&lt;/div&gt;
</code></pre>
<p>Save, then visit <code>http://localhost:3000/about</code>. Notice the home page lives at <code>localhost:3000/</code>, and the about page lives at <code>localhost:3000/about</code> — TanStack Start handled that routing automatically, purely based on the filename.</p>
<h3>Adding a Products Page</h3>
<p>Create <code>src/routes/products.tsx</code> the same way:</p>
<pre><code class="language-tsx">&lt;div className="p-8"&gt;
  &lt;h1 className="font-bold text-5xl text-slate-700"&gt;Products&lt;/h1&gt;
&lt;/div&gt;
</code></pre>
<p>Visit <code>localhost:3000/products</code>, 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.</p>
<h2>What Is That <code>__root.tsx</code> File?</h2>
<p>You've probably noticed a file called <code>__root.tsx</code> sitting in your routes folder. Here's the simplest way to think about it:</p>
<p>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.</p>
<p><code>__root.tsx</code> 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 <em>inside</em> this one file. Write it once, and every page automatically inherits it.</p>
<blockquote>
<p><strong>A word of caution:</strong> 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.</p>
</blockquote>
<h2>Summary</h2>
<p>In this part, we:</p>
<ul>
<li><p>Installed and configured a new TanStack Start project</p>
</li>
<li><p>Ran it locally and confirmed Tailwind works out of the box</p>
</li>
<li><p>Learned file-based routing by building About and Products pages</p>
</li>
<li><p>Got introduced to <code>__root.tsx</code>, the file behind every page in the app</p>
</li>
</ul>
<p>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.</p>
<hr />
<p><em>Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.</em></p>
]]></content:encoded></item><item><title><![CDATA[Directus Basics Part 3 — User Roles & Permissions]]></title><description><![CDATA[https://www.youtube.com/watch?v=qAk3X4y8v1U

This is part three of our Directus Basics series. In part one we set up our instance, and in part two we covered relationships between collections. Today w]]></description><link>https://blog.northernrangedigital.com/directus-basics-part-3-user-roles-permissions</link><guid isPermaLink="true">https://blog.northernrangedigital.com/directus-basics-part-3-user-roles-permissions</guid><category><![CDATA[directus]]></category><category><![CDATA[permissions]]></category><category><![CDATA[policies]]></category><category><![CDATA[roles]]></category><category><![CDATA[directus-permissions]]></category><category><![CDATA[directus-access-policies]]></category><category><![CDATA[directus-roles]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Fri, 21 Aug 2026 06:28:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/7dab12f8-756c-4825-82de-2f5de871b4c7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=qAk3X4y8v1U">https://www.youtube.com/watch?v=qAk3X4y8v1U</a></p>

<p>This is part three of our Directus Basics series. In <a href="https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;t=12s">part one</a> we set up our instance, and in <a href="https://www.youtube.com/watch?v=7SNrBzaxreg&amp;t=108s">part two</a> we covered relationships between collections. Today we're covering access control — what determines who can see and touch your data.</p>
<h2>The Core Concepts</h2>
<p>Access control in Directus comes down to three terms:</p>
<ul>
<li><p><strong>Permission</strong> — applies to one collection and one action (create, read, update, delete, or share). Can be full access, no access, or custom rules.</p>
</li>
<li><p><strong>Policy</strong> — a group of permissions bundled together, applied to users or roles.</p>
</li>
<li><p><strong>Role</strong> — 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.</p>
</li>
</ul>
<h2>What Happens Without a Role?</h2>
<p>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.</p>
<p>Creating a role and assigning the user to it isn't enough either — logging in at that point returns a <strong>"No App Access"</strong> error. The missing piece is that the role has no <strong>access policy</strong> attached. The policy is what actually tells the role what its users can and can't do.</p>
<h2>The Access Order</h2>
<p>Access in Directus flows in a specific direction:</p>
<p><strong>Access Policy → Role → User</strong></p>
<p>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.</p>
<h2>Default Policies</h2>
<p>Every fresh Directus instance ships with two default policies:</p>
<ul>
<li><p><strong>Administrator</strong> — the role you're signed in as by default, with unrestricted access to everything.</p>
</li>
<li><p><strong>Public</strong> — 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.</p>
</li>
</ul>
<blockquote>
<p><strong>Rule of thumb:</strong> give the Public policy Read access only. Never Create, Update, or Delete. The public should be able to view data, never manipulate it.</p>
</blockquote>
<h3>Giving a Collection Public Access</h3>
<ol>
<li><p>Go to <strong>Settings</strong> → <strong>Access Policies</strong> → <strong>Public</strong></p>
</li>
<li><p>Under Permissions, click <strong>Add Collection</strong></p>
</li>
<li><p>Select <strong>products</strong></p>
</li>
<li><p>Click <strong>Read</strong>, choose <strong>All Access</strong></p>
</li>
<li><p>Save</p>
</li>
</ol>
<h2>Creating a Custom Access Policy</h2>
<p>Let's build a policy for a data-entry team member with limited access.</p>
<ol>
<li><p>Go to <strong>Access Policies</strong>, click <strong>Create</strong></p>
</li>
<li><p>Name it <code>DataEntry</code></p>
</li>
<li><p>Check <strong>App Access</strong> — leave <strong>Admin Access</strong> unchecked, since that grants unrestricted control</p>
</li>
<li><p>Save</p>
</li>
</ol>
<p>Directus populates the policy with minimum defaults for its own system collections, but nothing for your custom collections yet.</p>
<p>Add products to it:</p>
<ol>
<li><p>Click <strong>Add Collection</strong>, select <code>products</code></p>
</li>
<li><p>Click <strong>Read</strong>, choose <strong>All Access</strong></p>
</li>
<li><p>Save</p>
</li>
</ol>
<h2>Creating the User Role</h2>
<ol>
<li><p>Go to <strong>User Roles</strong>, click <strong>Create</strong></p>
</li>
<li><p>Name it <code>DataEntry</code></p>
</li>
<li><p>Under Policies, click <strong>Add Existing</strong>, select the <code>DataEntry</code> policy</p>
</li>
<li><p>Save</p>
</li>
</ol>
<h2>Creating the User</h2>
<ol>
<li><p>Go to <strong>User Directory</strong>, click <strong>Create</strong></p>
</li>
<li><p>Fill in first name, last name, email, and password</p>
</li>
<li><p>Under <strong>Role</strong>, select <code>DataEntry</code></p>
</li>
<li><p>Save</p>
</li>
</ol>
<p>If you check back on <strong>User Roles</strong>, you'll see a <code>1</code> next to DataEntry under Users, and a matching <code>1</code> under Access Policies. That confirms the role, policy, and user are all wired together correctly.</p>
<blockquote>
<p><strong>Tip:</strong> giving your policy and role the same name (as done here with "DataEntry") makes them much easier to track as your project grows.</p>
</blockquote>
<h2>Testing Read-Only Access</h2>
<p>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.</p>
<p>Scrolling to the bottom of a product's detail page, some data is simply missing — those fields belong to <em>other</em> collections the user doesn't yet have permission for. The product image is missing too.</p>
<h2>Fixing the Missing File Permission</h2>
<p>Back in the <code>DataEntry</code> policy, notice <code>directus_files</code> isn't included among the accessible system collections. Without it, uploaded files — including product images — stay invisible.</p>
<ol>
<li><p>Click <strong>Add Collection</strong>, select <code>directus_files</code></p>
</li>
<li><p>Click <strong>Read</strong>, choose <strong>All Access</strong></p>
</li>
<li><p>Save</p>
</li>
</ol>
<p>Log back in as the data-entry user, and the product image now appears.</p>
<h3>Adding the Thumbnail to List View</h3>
<ol>
<li><p>In list view, click the <strong>+</strong> icon on the right</p>
</li>
<li><p>Scroll to <strong>image</strong>, click the arrow next to it — not the field name itself, or you'll add the raw file ID instead of the thumbnail</p>
</li>
<li><p>Scroll to <strong>thumbnail</strong>, click outside the dropdown to confirm</p>
</li>
</ol>
<p>Drag column headers to reorder them as needed.</p>
<h2>Extending Access to Related Collections</h2>
<p>The same missing-permission pattern applies to <strong>branding</strong>, <strong>products_tags</strong> and <strong>tags</strong> — add all to the <code>DataEntry</code> policy with Read access, and the relational fields on products become visible.</p>
<blockquote>
<p><strong>Key takeaway:</strong> you can't access a relational field in one collection unless you also have permission on the <em>related</em> collection. Both sides of the relationship need coverage.</p>
</blockquote>
<h2>Custom Field-Level Permissions</h2>
<p>Sometimes all-or-nothing collection access isn't granular enough. Directus lets you restrict access down to individual fields.</p>
<p>Let's allow the data-entry user to update only the <code>name</code> field on products:</p>
<ol>
<li><p>On the products collection, click the <strong>Update</strong> action</p>
</li>
<li><p>Choose <strong>Use Custom</strong></p>
</li>
<li><p>Click <strong>Field Permissions</strong></p>
</li>
<li><p>Check <strong>Name</strong></p>
</li>
<li><p>Save, then save again</p>
</li>
</ol>
<p>Logging back in, the data-entry user can now edit the Name field — and nothing else on that collection.</p>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Definition</th>
</tr>
</thead>
<tbody><tr>
<td>Permission</td>
<td>One collection + one action, set to full/none/custom</td>
</tr>
<tr>
<td>Policy</td>
<td>A bundle of permissions, applied to users or roles</td>
</tr>
<tr>
<td>Role</td>
<td>A user's position in the project; holds policies, applies to users</td>
</tr>
</tbody></table>
<p>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.</p>
<p>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.</p>
<hr />
<p><em>Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.</em></p>
]]></content:encoded></item><item><title><![CDATA[Directus Basics Part 2 — Understanding Relationships (M2O, O2M, M2M)]]></title><description><![CDATA[https://www.youtube.com/watch?v=7SNrBzaxreg&t=10s

This is part two of our Directus Basics series. In part one, we set up a Directus instance and created our first collection. Today we're covering one]]></description><link>https://blog.northernrangedigital.com/directus-basics-part-2-understanding-relationships-m2o-o2m-m2m</link><guid isPermaLink="true">https://blog.northernrangedigital.com/directus-basics-part-2-understanding-relationships-m2o-o2m-m2m</guid><category><![CDATA[webdev]]></category><category><![CDATA[directus]]></category><category><![CDATA[headless cms]]></category><category><![CDATA[hostinger]]></category><category><![CDATA[coolify hosting]]></category><category><![CDATA[vps]]></category><category><![CDATA[SQL]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Relational Database]]></category><category><![CDATA[O2M]]></category><category><![CDATA[m2o]]></category><category><![CDATA[M2M]]></category><category><![CDATA[one to many]]></category><category><![CDATA[many-to-one]]></category><category><![CDATA[many to many]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 18 Aug 2026 00:53:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/d4e0cba2-1ea7-48eb-9403-38ecc459676f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=7SNrBzaxreg&amp;t=10s">https://www.youtube.com/watch?v=7SNrBzaxreg&amp;t=10s</a></p>

<p>This is part two of our Directus Basics series. In <a href="https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;t=11s">part one</a>, we set up a Directus instance and created our first collection. Today we're covering one of the most powerful features Directus offers: <strong>relationships</strong> between collections.</p>
<p>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.</p>
<h2>Setting Up the Branding Collection</h2>
<p>Continuing from our existing Directus instance, let's create a new collection called <strong>branding</strong>. Select all the additional default fields Directus offers.</p>
<p>Add two fields:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
</tr>
</thead>
<tbody><tr>
<td><code>name</code></td>
<td>String (basic input)</td>
</tr>
<tr>
<td><code>description</code></td>
<td>Text (textarea)</td>
</tr>
</tbody></table>
<p>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.</p>
<h2>Many to One (M2O): Products → Branding</h2>
<p>Head over to your <strong>products</strong> collection. We want to connect a product to a brand.</p>
<p>Logically: many products can belong to <em>one</em> brand. That's a Many to One relationship — many products, to one brand.</p>
<p>Steps:</p>
<ol>
<li><p>Create Field → Relational category → <strong>Many to One</strong></p>
</li>
<li><p>Key: <code>brand_id</code></p>
</li>
<li><p>Related Collection: <code>branding</code></p>
</li>
<li><p>De-select <strong>Enable Create Button</strong> — you always want to choose a brand from an existing list, not create one on the fly</p>
</li>
<li><p>Save</p>
</li>
</ol>
<h2>One to Many (O2M): Branding → Products</h2>
<p>Since one brand can have <em>many</em> products, we need to set up the reverse relationship explicitly on the <strong>branding</strong> collection.</p>
<p>Steps:</p>
<ol>
<li><p>Create Field → Relational category → <strong>One to Many</strong></p>
</li>
<li><p>Key: <code>products</code></p>
</li>
<li><p>Related Collection: <code>products</code></p>
</li>
<li><p>Foreign Key: <code>brand_id</code> — the field we just created on the products side</p>
</li>
<li><p>De-select <strong>Enable Create Button</strong></p>
</li>
<li><p>Save</p>
</li>
</ol>
<h2>Seeing the Relationship in Action</h2>
<p>Go back into your <strong>products</strong> collection and assign a brand to each product. Make sure at least two products share the same brand so the relationship is visible.</p>
<p>You'll notice each product can only belong to <strong>one</strong> brand — that's the M2O relationship at work.</p>
<p>Now open the <strong>branding</strong> 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.</p>
<h2>Many to Many (M2M): Products ↔ Tags</h2>
<p>M2M relationships work differently — both sides can have many related records.</p>
<p>A common real-world example: <strong>tags</strong> on products, used to improve SEO. A single product can have multiple tags, and a single tag can apply to many products.</p>
<h3>Create the Tags Collection</h3>
<p>Create a new collection called <strong>tags</strong>, with the same two fields as before:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
</tr>
</thead>
<tbody><tr>
<td><code>name</code></td>
<td>String</td>
</tr>
<tr>
<td><code>description</code></td>
<td>Text (textarea)</td>
</tr>
</tbody></table>
<p>Add three or four tags before moving on.</p>
<h3>Wire Up the M2M Relationship</h3>
<p>Back in the <strong>products</strong> collection:</p>
<ol>
<li><p>Create Field → Relational category → <strong>Many to Many</strong></p>
</li>
<li><p>Key: <code>tags</code></p>
</li>
<li><p>Related Collection: <code>tags</code></p>
</li>
<li><p>De-select <strong>Enable Create Button</strong></p>
</li>
<li><p><strong>Do not save yet</strong> — scroll down and click <strong>"Continue in Advanced Field Creation Mode"</strong></p>
</li>
<li><p>In the sidebar, click <strong>Relationship</strong>, then find <strong>Corresponding Field</strong></p>
</li>
<li><p>Check <strong>"Create Field"</strong> — under Field Name, you should see <code>products</code> appear</p>
</li>
<li><p>Save</p>
</li>
</ol>
<blockquote>
<p><strong>Why the advanced flow?</strong> Under Corresponding Field, <code>products</code> appears automatically — that's Directus telling you it's about to create the matching relational field back on the <strong>tags</strong> collection. Unlike the M2O/O2M setup, you don't have to manually configure both sides — Directus builds the reverse relationship for you.</p>
</blockquote>
<p>Now, when you add a tag to a product, Directus automatically links that product under the corresponding tag in the <strong>tags</strong> collection — no manual syncing required.</p>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Relationship</th>
<th>Direction</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td>M2O</td>
<td>Many records → one related record</td>
<td>Products → Branding</td>
</tr>
<tr>
<td>O2M</td>
<td>One record → many related records</td>
<td>Branding → Products</td>
</tr>
<tr>
<td>M2M</td>
<td>Many ↔ many, both directions</td>
<td>Products ↔ Tags</td>
</tr>
</tbody></table>
<p>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.</p>
<p>That wraps up part two. In part three, we'll cover Directus <strong>permissions</strong> — how to control exactly who can see and edit your data.</p>
<hr />
<p><em>Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version on</em> <a href="https://www.youtube.com/watch?v=7SNrBzaxreg&amp;t=11s"><em>YouTube</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Directus Collections Explained (Fields, Types & Setup) — Directus Basics Part 1]]></title><description><![CDATA[https://www.youtube.com/watch?v=tJGuxqhv2SY&t=5s


This is Part 1 of a three-part series on Directus fundamentals: Collections (this post), Relationships, and Permissions. New to Directus? Start here.]]></description><link>https://blog.northernrangedigital.com/directus-collections-explained-fields-types-setup-directus-basics-part-1</link><guid isPermaLink="true">https://blog.northernrangedigital.com/directus-collections-explained-fields-types-setup-directus-basics-part-1</guid><category><![CDATA[directus]]></category><category><![CDATA[Collections]]></category><category><![CDATA[field-types]]></category><category><![CDATA[directus-data-studio]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Sat, 08 Aug 2026 23:48:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/528479d3-8efb-458b-b515-8edbdd87fc08.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;t=5s">https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;t=5s</a></p>

<blockquote>
<p>This is Part 1 of a three-part series on Directus fundamentals: <strong>Collections</strong> (this post), <strong>Relationships</strong>, and <strong>Permissions</strong>. New to Directus? Start here.</p>
</blockquote>
<h2>Introduction</h2>
<p>Collections are the bread and butter of the Directus Data Studio. According to <a href="https://directus.com/docs">Directus's own docs</a>, collections are database tables — with extra metadata and configuration layered on top by Directus.</p>
<p>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.</p>
<h2>Two Ways to Create a Collection</h2>
<p>There are two paths to collection creation inside the Data Studio.</p>
<p><strong>Option 1:</strong> Log into your Data Studio and look at the sidebar for the icon that looks like a 3D box — that's <strong>Content</strong>. Click it, and on the main screen you'll see <strong>Create Collection</strong>.</p>
<p><strong>Option 2:</strong> Scroll down the sidebar to the gear icon — <strong>Settings</strong>. Click in, and at the top you'll find <strong>Data Model</strong>. Same deal from there — <strong>Create Collection</strong> is right on the main screen.</p>
<p>Either path lands you in the same place.</p>
<h2>Creating a Collection</h2>
<ul>
<li><p>Click <strong>Create Collection</strong></p>
</li>
<li><p>Name it something intuitive — it should describe exactly what data you're storing. For this example, we're creating a collection called <strong>products</strong></p>
</li>
<li><p>Ignore <strong>Singleton</strong> for now (more on this below)</p>
</li>
<li><p>In the <strong>Type</strong> dropdown, select <strong>Generated UUID</strong></p>
</li>
<li><p>Click <strong>Next</strong>, select all the boxes offered, and click <strong>Finish Setup</strong></p>
</li>
</ul>
<blockquote>
<p><strong>What is Singleton?</strong> You'd only check this if the collection will ever hold exactly one item — not the case here, since we're storing many products.</p>
<p><strong>What is the Generated UUID?</strong> A unique ID automatically assigned to every item in the collection, so you never have to manage IDs manually.</p>
</blockquote>
<p>Once created, you land on the collection's screen, ready to build its data structure.</p>
<p><strong>Bonus:</strong> under <strong>Collection Setup</strong>, 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.</p>
<h2>Creating Fields Within a Collection</h2>
<p>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 <strong>products</strong> collection:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><code>name</code></td>
<td>Input (string)</td>
<td>The product's name</td>
</tr>
<tr>
<td><code>brief</code></td>
<td>Textarea</td>
<td>Short description — more room than Input, no special formatting needed</td>
</tr>
<tr>
<td><code>description</code></td>
<td>WYSIWYG</td>
<td>Full rich-text formatting — paragraphs, bullet lists, H1–H4 headings</td>
</tr>
<tr>
<td><code>price</code></td>
<td>Input (decimal)</td>
<td>See Precision &amp; Scale below</td>
</tr>
<tr>
<td><code>sale</code></td>
<td>Input (decimal)</td>
<td>Same setup as <code>price</code></td>
</tr>
<tr>
<td><code>isOnSale</code></td>
<td>Boolean</td>
<td>Toggle the front end can check to decide which price to show</td>
</tr>
<tr>
<td><code>category</code></td>
<td>Dropdown</td>
<td>Product type — e.g. jersey, pants</td>
</tr>
<tr>
<td><code>quantity</code></td>
<td>Input (integer)</td>
<td>Minimum value of 1</td>
</tr>
<tr>
<td><code>image</code></td>
<td>File</td>
<td>Product photo</td>
</tr>
</tbody></table>
<h3>Setting Up the Price Field (Precision &amp; Scale)</h3>
<p>The <code>price</code> field needs a bit of extra configuration. Instead of clicking <strong>Save</strong> right away, look just below the Save button for <strong>Continue in Advanced Field Creation Mode</strong>.</p>
<p>In there, you'll find <strong>Precision</strong> and <strong>Scale</strong>, defaulting to <code>10</code> and <code>5</code>.</p>
<ul>
<li><p><strong>Precision</strong> — the total number of digits the field accepts</p>
</li>
<li><p><strong>Scale</strong> — how many of those digits sit after the decimal point</p>
</li>
</ul>
<p>Left at the defaults, you'd get something like <code>12345.67890</code>. For a price field, that's overkill — we want 7 digits total, 2 of them after the decimal point. So: <strong>Precision = 7</strong>, <strong>Scale = 2</strong>. Adjust to fit your own use case if you need more decimal precision.</p>
<p>Build <code>sale</code> the exact same way.</p>
<blockquote>
<p><strong>Note on relational fields:</strong> 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.</p>
</blockquote>
<h2>Adding Your Data</h2>
<p>Head to the sidebar and click the box icon — <strong>Content</strong>. You'll see your <strong>Products</strong> collection listed. Click <strong>Create Item</strong>, and fill in the fields with real data.</p>
<p>For this example, add a couple of items — a jersey and a pair of jeans — to see the collection actually holding data.</p>
<h2>Summary</h2>
<p>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: <strong>relationships</strong> — how collections connect to each other, which is where Directus really starts to shine.</p>
<p>Questions or stuck on something? Drop a comment below.</p>
]]></content:encoded></item><item><title><![CDATA[Directus + Coolify: Should You Decouple Postgres & Redis?]]></title><description><![CDATA[https://www.youtube.com/watch?v=6cMrbfGZqb8


This is Part 2 of the Directus + Coolify series. If you're new here, start with "Secure Your VPS Before Hackers Do" and the first Directus + Coolify post ]]></description><link>https://blog.northernrangedigital.com/directus-coolify-should-you-decouple-postgres-redis</link><guid isPermaLink="true">https://blog.northernrangedigital.com/directus-coolify-should-you-decouple-postgres-redis</guid><category><![CDATA[coolify]]></category><category><![CDATA[ansible]]></category><category><![CDATA[directus]]></category><category><![CDATA[hostinger]]></category><category><![CDATA[Redis]]></category><category><![CDATA[postgres]]></category><category><![CDATA[vps]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Thu, 06 Aug 2026 21:50:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/26bdbcbf-9895-43c8-9c0d-9fde2d84eea8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=6cMrbfGZqb8">https://www.youtube.com/watch?v=6cMrbfGZqb8</a></p>

<blockquote>
<p>This is Part 2 of the Directus + Coolify series. If you're new here, start with <strong>"Secure Your VPS Before Hackers Do"</strong> and the first Directus + Coolify post — the bundled, single-Compose-file setup — before following along with this one.</p>
</blockquote>
<h2>Introduction</h2>
<p>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.</p>
<p>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.</p>
<p>Most of them turned out to be wrong.</p>
<h2>Myth 1: Restarting Directus Restarts the Whole Stack</h2>
<p>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 <strong>Restart</strong> button, right there in the same view. No decoupling needed for this one.</p>
<h2>Myth 2: You Need a Separate Database Resource for S3 Backups</h2>
<p>Same story. Even with Postgres bundled inside the Directus Compose file, Coolify still gives it its own dedicated <strong>Backups</strong> option, S3 included. This isn't a separate-resource-only feature.</p>
<h2>Myth 3: Scheduled Tasks Require Separate Services</h2>
<p>Also not true. Coolify exposes a <strong>Scheduled Tasks</strong> tab per service, even inside a single bundled stack — complete with a <strong>Container name</strong> dropdown letting you target the cron job at just the database, or just Directus, without splitting anything apart.</p>
<h2>What Actually Holds Up</h2>
<p>Two things survived testing.</p>
<p><strong>First: metrics.</strong> 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.</p>
<p><strong>Second: sharing a database across multiple apps.</strong> 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.</p>
<p>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.</p>
<h2>Adding the Resources</h2>
<h3>Adding the PostgreSQL Database</h3>
<ul>
<li><p><strong>Dashboard → Add Project</strong></p>
</li>
<li><p><strong>+ Add Resource</strong></p>
</li>
<li><p><strong>Databases → PostgreSQL → Supabase PostgreSQL (with extensions)</strong></p>
</li>
<li><p>Change the name to something human-friendly</p>
</li>
<li><p>Copy your username and password and save them somewhere — you'll need them shortly</p>
</li>
<li><p>Click <strong>Save</strong></p>
</li>
<li><p>Click <strong>Start</strong>, and wait for the database to spin up (this can take a little while)</p>
</li>
</ul>
<p>Once it's up, the status should read <strong>"Running (Healthy)."</strong></p>
<blockquote>
<p>💡 <strong>Enable metrics while you're here.</strong> In the sidebar, go to <strong>Servers → localhost → Metrics</strong>, and enable metrics. Back in your project, under <strong>Databases</strong>, click your Postgres database, then <strong>Metrics</strong> — you should now see live CPU/memory usage for it. This is the exact capability that isn't available on a bundled Compose deployment.</p>
</blockquote>
<h3>Adding Redis Cache</h3>
<ul>
<li><p><strong>Dashboard → + Add Resource</strong> (from the project itself)</p>
</li>
<li><p><strong>Databases → Redis</strong></p>
</li>
<li><p>Rename it to something more convenient</p>
</li>
<li><p>Copy the Redis connection URL — you'll need it shortly</p>
</li>
<li><p><strong>Save</strong>, then <strong>Start</strong></p>
</li>
</ul>
<p>Once both are running, SSH into your VPS and run:</p>
<pre><code class="language-bash">docker ps
</code></pre>
<p>to confirm both containers are up.</p>
<h3>The Docker Compose Config for Directus</h3>
<p>This is what goes into the empty Compose file for the Directus resource:</p>
<pre><code class="language-yaml">services:
  directus:
    image: 'directus/directus:12.2.0'
    ports:
      - '8055:8055'
    volumes:
      - './uploads:/directus/uploads'
      - './extensions:/directus/extensions'
    healthcheck:
      test:
        - CMD-SHELL
        - 'wget --spider -q http://127.0.0.1:8055/server/ping || exit 1'
      interval: 10s
      timeout: 5s
      retries: 5
      start_interval: 5s
      start_period: 30s
    environment:
      SECRET: secretstring
      MARKETPLACE_TRUST: all
      DB_CLIENT: pg
      DB_HOST:
      DB_PORT: '5432'
      DB_DATABASE: postgres
      DB_USER: postgres
      DB_PASSWORD:
      CACHE_ENABLED: 'true'
      CACHE_STORE: redis
      CACHE_AUTO_PURGE: 'true'
      REDIS:
      ADMIN_EMAIL: joepublic@example.com
      ADMIN_PASSWORD: '1234567890'
      CORS_ENABLED: 'true'
      CORS_ORIGIN: 'true'
      CORS_CREDENTIALS: 'true'
      PUBLIC_URL:
</code></pre>
<blockquote>
<p>Notice the healthcheck already uses <code>127.0.0.1</code> instead of <code>localhost</code> — that's the fix from Part 1. Carrying it forward here saves you from hitting the exact same "unhealthy" bug all over again.</p>
</blockquote>
<h3>Adding Directus</h3>
<ul>
<li><p><strong>Dashboard → + Add Resource</strong> (from the project itself)</p>
</li>
<li><p><strong>Applications → Docker Compose Empty</strong></p>
</li>
<li><p>Paste in the Compose configuration above</p>
</li>
<li><p>Click <strong>Save</strong></p>
</li>
<li><p><strong>Network → Connect To Predefined Network</strong> → check the box</p>
</li>
<li><p><strong>Services → Directus service → Settings</strong></p>
</li>
<li><p>Add your Directus subdomain — remember to use <code>https://</code> (e.g. <code>https://directus.yourdomain.com</code>)</p>
</li>
<li><p><strong>Save</strong></p>
</li>
</ul>
<h3>Connecting the Services Together</h3>
<table>
<thead>
<tr>
<th>Variable</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><code>SECRET</code></td>
<td>A long, unguessable random string</td>
</tr>
<tr>
<td><code>DB_HOST</code></td>
<td>The name of your Postgres container — run <code>docker ps</code> on your VPS to find it</td>
</tr>
<tr>
<td><code>DB_DATABASE</code></td>
<td>The name of the database on your Postgres server (Coolify's default is usually <code>postgres</code>, but confirm it against your Postgres service)</td>
</tr>
<tr>
<td><code>DB_USER</code></td>
<td>The database username, from the Postgres service you created earlier</td>
</tr>
<tr>
<td><code>DB_PASSWORD</code></td>
<td>The password from that same Postgres service</td>
</tr>
<tr>
<td><code>REDIS</code></td>
<td>The Redis connection URL from the Redis service you created earlier</td>
</tr>
<tr>
<td><code>PUBLIC_URL</code></td>
<td>Your Directus subdomain</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ <strong>Redis URL gotcha:</strong> the connection URL follows the format <code>redis://username:password@host:port</code>. Coolify's generated URL includes the username you set when creating the Redis resource — in my case, that username was also <code>redis</code>, so the URL looked like <code>redis://redis:somelongvariable...</code>. Directus doesn't need the username here, just the password, so strip that segment out: <code>redis://:somelongvariable...</code>. If you used a different username when creating your Redis resource, remove <em>that</em> value instead — not literally the word "redis."</p>
</blockquote>
<h2>Setting Up Environment Variables</h2>
<p>Rather than hardcoding any of this directly into the Compose file, move it into Directus's environment variables:</p>
<ul>
<li><p><strong>Dashboard → Projects → Services / Directus</strong></p>
</li>
<li><p><strong>Environment Variables</strong></p>
</li>
<li><p>Click <strong>+ Add</strong>, enter the variable name in all caps, and its value</p>
</li>
<li><p><strong>Save</strong></p>
</li>
<li><p>Repeat for: <code>SECRET</code>, <code>DB_HOST</code>, <code>DB_DATABASE</code>, <code>DB_USER</code>, <code>DB_PASSWORD</code>, <code>ADMIN_EMAIL</code>, <code>ADMIN_PASSWORD</code>, and <code>REDIS</code></p>
</li>
</ul>
<h3>Updating the Compose File</h3>
<p>Now reference those variables instead of the raw values:</p>
<pre><code class="language-yaml">SECRET: '${SECRET}'
DB_HOST: '${DB_HOST}'
</code></pre>
<p>...and so on for each variable. Then:</p>
<ul>
<li><p><strong>Save</strong></p>
</li>
<li><p><strong>Restart</strong></p>
</li>
</ul>
<h2>Launching Directus</h2>
<p>Paste your Directus subdomain into the browser, and you should land on your running Directus instance. You can also click <strong>Links</strong> on the Directus service, then click the subdomain — it'll take you straight to your Directus Studio login.</p>
<p>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.</p>
<hr />
<p>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.</p>
]]></content:encoded></item><item><title><![CDATA[Setting Up Directus on a Coolify VPS (And Fixing the "Unhealthy" Error)]]></title><description><![CDATA[https://www.youtube.com/watch?v=OMX1Rk1HTZo

Introduction
Directus is a backend for developers. It can connect to any SQL database and asset storage, and it provides developer tooling — which the Dire]]></description><link>https://blog.northernrangedigital.com/setting-up-directus-on-a-coolify-vps-and-fixing-the-unhealthy-error</link><guid isPermaLink="true">https://blog.northernrangedigital.com/setting-up-directus-on-a-coolify-vps-and-fixing-the-unhealthy-error</guid><category><![CDATA[coolify]]></category><category><![CDATA[directus]]></category><category><![CDATA[hostinger]]></category><category><![CDATA[coolify hosting]]></category><category><![CDATA[coolify vps hosting]]></category><category><![CDATA[directus-self-hostig]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[redis-cache]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Sun, 02 Aug 2026 14:35:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/c1044337-500b-471a-a4bd-9b4b0488e33c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p><a class="embed-card" href="https://www.youtube.com/watch?v=OMX1Rk1HTZo">https://www.youtube.com/watch?v=OMX1Rk1HTZo</a></p>

<h2>Introduction</h2>
<p>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."</p>
<p>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.</p>
<p>Directus is also known as a <strong>headless CMS</strong> — 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.</p>
<p>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.</p>
<blockquote>
<p>📌 <strong>New here?</strong> 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.</p>
</blockquote>
<h2>Let's Get Started</h2>
<p>There are two ways to set up Directus on a Coolify-managed VPS. I'll cover the first one here.</p>
<h3>1. Log in to your Coolify Dashboard</h3>
<ul>
<li><p>In the sidebar menu, go to <strong>Projects → +Add</strong></p>
</li>
<li><p>Give your project a name and description, and press continue</p>
</li>
</ul>
<h3>2. Add a Resource</h3>
<p>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 <strong>Projects</strong> in the sidebar, then click into your project, and you should see <strong>+Add Resource</strong>.</p>
<ul>
<li><p>Click <strong>+Add Resource</strong></p>
</li>
<li><p>Under Applications, select <strong>Docker Compose Empty</strong></p>
</li>
<li><p>A blank Docker Compose file will open</p>
</li>
<li><p>Go to <a href="https://directus.com">directus.com</a> → <strong>Docs</strong> → <strong>Hosting</strong> → <strong>Deployment</strong></p>
</li>
<li><p>Scroll down until you find the example Docker Compose YAML file, and copy it</p>
</li>
<li><p>Paste it into your blank Coolify Compose file</p>
</li>
<li><p>Find the <code>directus:</code> service, and under it, <code>image:</code> — update this to the version you want. As of this post, the latest version is <code>12.2.0</code>:</p>
<pre><code class="language-yaml">image: directus/directus:12.2.0
</code></pre>
</li>
<li><p>Scroll to the end and set your own admin email and password — these become your Data Studio login credentials</p>
</li>
<li><p>Click <strong>Save</strong> at the top</p>
</li>
</ul>
<h3>3. Configure the Service</h3>
<p>You should now be on the <strong>Configuration</strong> screen, with a <strong>Deploy</strong> button (yellow outline arrow) in the top right.</p>
<ul>
<li><p>Under <strong>Network</strong>, check the box</p>
</li>
<li><p>Under <strong>Services</strong>, click <strong>Settings</strong> on the <code>directus</code> tab</p>
</li>
<li><p>In the <strong>Domain</strong> field, enter your subdomain — e.g. <code>https://directus.yourdomain.com</code>. <strong>Use</strong> <code>https</code>, so Traefik knows to issue a Let's Encrypt certificate for it</p>
</li>
<li><p>Click <strong>Save</strong>, then <strong>Back</strong></p>
</li>
<li><p>Click <strong>Edit Compose File</strong> at the top, scroll down to <code>PUBLIC_URL</code>, and set it to match your subdomain:</p>
<pre><code class="language-yaml">PUBLIC_URL: https://directus.yourdomain.com
</code></pre>
</li>
<li><p>Click <strong>Save</strong>, close the modal</p>
</li>
<li><p>Click <strong>Deploy</strong>, then <strong>Confirm</strong> on the popup</p>
</li>
<li><p>Wait for the deployment to finish</p>
</li>
</ul>
<h3>4. Launch Directus</h3>
<p>On the Configuration screen, click <strong>Links</strong>, then click your new subdomain.</p>
<h2>The Errors</h2>
<p>At this point, your browser will show two errors: a <strong>"Not Secure"</strong> warning in the address bar, and a <strong>"no available server"</strong> page.</p>
<p>Back on the Coolify dashboard, you'll notice the Directus instance is showing as <strong>unhealthy</strong>. 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 <em>why</em> the health check is failing.</p>
<h2>Diagnosing the Problem</h2>
<p>SSH into your VPS to start digging.</p>
<p><strong>Check your running containers:</strong></p>
<pre><code class="language-bash">sudo docker ps
</code></pre>
<p><strong>Install</strong> <code>jq</code>, so the JSON output we're about to read is actually readable:</p>
<pre><code class="language-bash">sudo apt install jq
</code></pre>
<p><strong>Check the health check failure log</strong> (grab your Directus container's name from the <code>docker ps</code> output above):</p>
<pre><code class="language-bash">docker inspect --format='{{json .State.Health}}' &lt;your-directus-container-name&gt; | jq
</code></pre>
<p>The result looked like this:</p>
<pre><code class="language-json">{
  "Status": "unhealthy",
  "FailingStreak": 79,
  "Log": [
    {
      "Start": "2026-07-30T18:12:21.445964902-04:00",
      "End": "2026-07-30T18:12:21.502447668-04:00",
      "ExitCode": 1,
      "Output": "wget: can't connect to remote host: Connection refused\n"
    },
    {
      "Start": "2026-07-30T18:12:31.505411263-04:00",
      "End": "2026-07-30T18:12:31.556307544-04:00",
      "ExitCode": 1,
      "Output": "wget: can't connect to remote host: Connection refused\n"
    }
  ]
}
</code></pre>
<p>The first clue: <code>"wget: can't connect to remote host: Connection refused"</code>. 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.</p>
<h2>Inspecting the Docker Compose File</h2>
<ul>
<li><p>From the Coolify dashboard, select <strong>Projects</strong> in the sidebar</p>
</li>
<li><p>Click your project</p>
</li>
<li><p>Click the service, then <strong>Edit Compose File</strong> next to <strong>Service Stack</strong></p>
</li>
</ul>
<p>Here's the relevant section:</p>
<pre><code class="language-yaml">directus:
  image: 'directus/directus:12.2.0'
  ports:
    - '8055:8055'
  volumes:
    - './uploads:/directus/uploads'
    - './extensions:/directus/extensions'
  depends_on:
    database:
      condition: service_healthy
    cache:
      condition: service_healthy
  healthcheck:
    test:
      - CMD-SHELL
      - 'wget --spider -q http://localhost:8055/server/ping || exit 1'
    interval: 10s
    timeout: 5s
    retries: 5
    start_interval: 5s
    start_period: 30s
</code></pre>
<p>The problem is in the <code>wget</code> line — specifically, <code>http://localhost:8055</code>.</p>
<h3>Why <code>localhost</code> Breaks This</h3>
<p>Inside a Linux container, <code>localhost</code> isn't an address — it's a hostname that has to be resolved first, and it typically maps to <strong>two</strong> addresses at once:</p>
<pre><code class="language-plaintext">127.0.0.1   localhost   # IPv4
::1         localhost   # IPv6
</code></pre>
<p>When <code>wget</code> looks up <code>localhost</code>, the system's resolver hands back both addresses, and <code>wget</code> tries them in whatever order it receives them — which, on many Linux/Alpine base images, tends to prefer <strong>IPv6 first</strong>.</p>
<p>Checking the container's logs confirms what's actually listening:</p>
<pre><code class="language-bash">docker logs &lt;your-directus-container-name&gt; --tail 150
</code></pre>
<p>Directus only bound to IPv4. The startup log says exactly that:</p>
<pre><code class="language-plaintext">Server started at http://0.0.0.0:8055
</code></pre>
<p><code>0.0.0.0</code> means "listen on every IPv4 interface" — Directus never opened an IPv6 socket at all. So when <code>wget</code> tries <code>::1:8055</code> 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 <strong>"Connection refused"</strong> 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.</p>
<p><code>127.0.0.1</code> 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.</p>
<h2>The Fix</h2>
<p>Change <code>localhost</code> to <code>127.0.0.1</code> in the health check:</p>
<pre><code class="language-yaml">directus:
  image: 'directus/directus:12.2.0'
  ports:
    - '8055:8055'
  volumes:
    - './uploads:/directus/uploads'
    - './extensions:/directus/extensions'
  depends_on:
    database:
      condition: service_healthy
    cache:
      condition: service_healthy
  healthcheck:
    test:
      - CMD-SHELL
      - 'wget --spider -q http://127.0.0.1:8055/server/ping || exit 1'
    interval: 10s
    timeout: 5s
    retries: 5
    start_interval: 5s
    start_period: 30s
</code></pre>
<p>Save the change, close the modal, and click <strong>Restart</strong> 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 <strong>healthy</strong>.</p>
<p>Click <strong>Links</strong>, then your Directus subdomain, and you should land on the Data Studio login screen.</p>
<h2>Logging In &amp; Licensing</h2>
<p>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 <strong>Core</strong> for now, and follow the flow to complete sign-up.</p>
<p>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.</p>
<p>Head to <a href="https://directus.com/oig">directus.com/oig</a> to apply for your key. Once you have it, go to your Directus Studio, click the gear icon (Settings) in the sidebar, click <strong>License</strong>, and add your key there to unlock all features. Your key is valid for a year.</p>
<hr />
<p>Got questions, or hit a different error setting this up? Drop a comment below — happy to help troubleshoot.</p>
]]></content:encoded></item><item><title><![CDATA[Coolify: The Complete Manual Setup Guide (For When the Auto-Install Script Won't Cut It)
]]></title><description><![CDATA[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 ]]></description><link>https://blog.northernrangedigital.com/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-won-t-cut-it</link><guid isPermaLink="true">https://blog.northernrangedigital.com/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-won-t-cut-it</guid><category><![CDATA[coolify]]></category><category><![CDATA[ansible]]></category><category><![CDATA[ansible-playbook]]></category><category><![CDATA[Traefik]]></category><category><![CDATA[Docker]]></category><category><![CDATA[ssh]]></category><category><![CDATA[hostinger]]></category><category><![CDATA[vps]]></category><category><![CDATA[VPS Hosting]]></category><category><![CDATA[vps server]]></category><category><![CDATA[Ansible automation ]]></category><category><![CDATA[self-hosted]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Thu, 23 Jul 2026 04:23:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/7672e085-a8c5-4c8f-a483-5b96c10b6a43.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=jlUzYm6W-bI">https://www.youtube.com/watch?v=jlUzYm6W-bI</a></p>

<h2>Minimum Hardware Requirements</h2>
<ul>
<li><p><strong>CPU:</strong> 2 cores</p>
</li>
<li><p><strong>Memory:</strong> 2 GB RAM</p>
</li>
<li><p><strong>Storage:</strong> 30 GB free</p>
</li>
</ul>
<p>Coolify can technically run below this, but it's not recommended.</p>
<h2>Prerequisites</h2>
<p>Before touching Coolify itself, you'll need:</p>
<ul>
<li><p>SSH access to your VPS</p>
</li>
<li><p>CURL installed</p>
</li>
<li><p>Docker Engine installed</p>
</li>
</ul>
<p>If you're reconnecting to a server you've rebuilt or re-provisioned, clear the old fingerprint first:</p>
<pre><code class="language-bash">ssh-keygen -f '/home/your-path/.ssh/known_hosts' -R 'your-vps-ip'
</code></pre>
<h3>Installing SSH</h3>
<p>If you followed the earlier videos in this series, OpenSSH is already installed. If not:</p>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt install -y openssh-server
</code></pre>
<p>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):</p>
<pre><code class="language-bash">sudo systemctl status ssh
sudo ss -tulpn | grep ssh
</code></pre>
<h3>Installing CURL</h3>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt install -y curl
curl --version
</code></pre>
<p><code>curl</code> and <code>ca-certificates</code> also get installed as part of the <code>apt-update</code> Ansible playbook below, so this may already be handled.</p>
<h2>Running the First Ansible Playbook</h2>
<p>Connect Ansible to the VPS:</p>
<pre><code class="language-bash">ANSIBLE_HOST_KEY_CHECKING=FALSE ansible -i ./inventory/hosts vpsDemo -m ping --user root --ask-pass
</code></pre>
<p>Then run the update playbook:</p>
<pre><code class="language-bash">ansible-playbook ./playbooks/apt-update.yml --user root -e "ansible_port=22" --ask-pass --ask-become-pass -i ./inventory/hosts
</code></pre>
<p>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.</p>
<h2>Installing Docker Engine</h2>
<p>Remove any conflicting packages first:</p>
<pre><code class="language-bash">sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1)
</code></pre>
<p>Add Docker's official GPG key and repo:</p>
<pre><code class="language-bash">sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

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

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
</code></pre>
<p>Verify it worked:</p>
<pre><code class="language-bash">sudo systemctl status docker
sudo docker run hello-world
</code></pre>
<h2>Creating a Non-Root Admin User</h2>
<p>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.</p>
<p>Run the second playbook to create that user:</p>
<pre><code class="language-bash">ansible-playbook ./playbooks/basic-secure.yml --user root -e "ansible_port=22" --ask-pass --ask-become-pass -i ./inventory/hosts
</code></pre>
<p>Then set up that user's SSH directory:</p>
<pre><code class="language-bash">sudo mkdir -p /home/your-sudo-user/.ssh
sudo touch /home/your-sudo-user/.ssh/authorized_keys
sudo chmod 700 /home/your-sudo-user/.ssh
sudo chmod 600 /home/your-sudo-user/.ssh/authorized_keys
</code></pre>
<h2>Setting Up Coolify's Directory Structure</h2>
<pre><code class="language-bash">sudo mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance}
sudo mkdir -p /data/coolify/ssh/{keys,mux}
sudo mkdir -p /data/coolify/proxy/dynamic
</code></pre>
<h2>Generating and Adding an SSH Key</h2>
<pre><code class="language-bash">sudo ssh-keygen -f /data/coolify/ssh/keys/id.your-sudo-user@host.docker.internal -t ed25519 -N '' -C your-sudo-user@coolify
</code></pre>
<p>Add the public key to the authorized_keys file:</p>
<pre><code class="language-bash">cat /data/coolify/ssh/keys/id.your-sudo-user@host.docker.internal.pub | sudo tee -a /home/your-sudo-user/.ssh/authorized_keys
</code></pre>
<h2>Pulling Coolify's Configuration Files</h2>
<pre><code class="language-bash">sudo curl -fsSL https://cdn.coollabs.io/coolify/docker-compose.yml -o /data/coolify/source/docker-compose.yml
sudo curl -fsSL https://cdn.coollabs.io/coolify/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
sudo curl -fsSL https://cdn.coollabs.io/coolify/.env.production -o /data/coolify/source/.env
sudo curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh
</code></pre>
<h2>Generating Secure Environment Values</h2>
<p>⚠️ <strong>Only run these once, on first install.</strong> Changing them later can break Coolify. Back them up somewhere safe.</p>
<pre><code class="language-bash">sudo sed -i "s|APP_ID=.*|APP_ID=$(openssl rand -hex 16)|g" /data/coolify/source/.env
sudo sed -i "s|APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|g" /data/coolify/source/.env
sudo sed -i "s|DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|g" /data/coolify/source/.env
sudo sed -i "s|REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|g" /data/coolify/source/.env
sudo sed -i "s|PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|g" /data/coolify/source/.env
sudo sed -i "s|PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|g" /data/coolify/source/.env
sudo sed -i "s|PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|g" /data/coolify/source/.env
</code></pre>
<h2>Permissions and Docker Setup</h2>
<p>Set correct ownership and permissions:</p>
<pre><code class="language-bash">sudo chown -R 9999:root /data/coolify
sudo find /data/coolify -type d -exec chmod 755 {} \;
sudo find /data/coolify -type f -exec chmod 644 {} \;
sudo chown -R your-sudo-user:your-sudo-user /home/your-sudo-user/.ssh
</code></pre>
<p>Create the Docker network Coolify expects:</p>
<pre><code class="language-bash">sudo docker network create --attachable coolify
</code></pre>
<p>Add your user to the Docker group:</p>
<pre><code class="language-bash">sudo usermod -aG docker your-sudo-user
</code></pre>
<h2>Starting Coolify</h2>
<pre><code class="language-bash">sudo docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --pull always --remove-orphans --force-recreate
</code></pre>
<p>Confirm it's running:</p>
<pre><code class="language-bash">sudo docker ps
</code></pre>
<p>Then visit <code>http://YOUR-SERVER-IP:8000</code> in your browser.</p>
<h2>Setup your SSL Certificates</h2>
<p>Coolify's reverse-proxy Traefik does this under the hood automatically. In your DNS records add two A records.</p>
<pre><code class="language-bash">A     @     your-vps-ip-address        14400
A     *     your-vps-ip-address        14400
</code></pre>
<p>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.</p>
<pre><code class="language-bash">https://coolify.yourdomain.com
</code></pre>
<p>Traefik will automatically apply a Let's Encrypt certificate to your Coolify subdomain.</p>
<h2>Disable port 8000</h2>
<p>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.</p>
<h2>Wrap-Up</h2>
<p>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.</p>
<p>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.</p>
]]></content:encoded></item><item><title><![CDATA[The Ansible Playbook that will Harden Your VPS]]></title><description><![CDATA[https://www.youtube.com/watch?v=vl8IW8F1mxA

Introduction
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 Ans]]></description><link>https://blog.northernrangedigital.com/the-ansible-playbook-that-will-harden-your-vps</link><guid isPermaLink="true">https://blog.northernrangedigital.com/the-ansible-playbook-that-will-harden-your-vps</guid><category><![CDATA[ansible]]></category><category><![CDATA[ansible-module]]></category><category><![CDATA[Ansible automation ]]></category><category><![CDATA[#ansible-adhoc]]></category><category><![CDATA[ansible inventory file]]></category><category><![CDATA[ansible project]]></category><category><![CDATA[VPS Hosting]]></category><category><![CDATA[vps-hardening]]></category><category><![CDATA[vps-security]]></category><category><![CDATA[Ubuntu]]></category><category><![CDATA[hostinger]]></category><category><![CDATA[VS Code]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 14 Jul 2026 20:54:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/361a578a-f993-4833-9b88-4480b0883766.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=vl8IW8F1mxA">https://www.youtube.com/watch?v=vl8IW8F1mxA</a></p>

<h2>Introduction</h2>
<p>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.</p>
<p>The three playbooks covered today:</p>
<ul>
<li><p><code>basic-secure.yml</code> — automate VPS hardening</p>
</li>
<li><p><code>add-vps-user.yml</code> — semi-automated user creation</p>
</li>
<li><p><code>remove-vps-user.yml</code> — completely remove a user and their privileges</p>
</li>
</ul>
<blockquote>
<p>📚 <strong>Resources:</strong></p>
<ul>
<li><p><a href="https://docs.ansible.com">Ansible Documentation</a></p>
</li>
<li><p><a href="https://docs.ansible.com/ansible/latest/collections/index.html">Ansible Collection Index</a></p>
</li>
</ul>
</blockquote>
<hr />
<h2>The Basics</h2>
<p>A task is broken up into four parts:</p>
<table>
<thead>
<tr>
<th>Part</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>A plain-text description of what the task does</td>
</tr>
<tr>
<td><strong>Collection</strong></td>
<td>The Ansible content bundle the module belongs to</td>
</tr>
<tr>
<td><strong>Module</strong></td>
<td>The tool that executes the action</td>
</tr>
<tr>
<td><strong>Parameters</strong></td>
<td>The specific options passed to the module</td>
</tr>
</tbody></table>
<hr />
<h2>Playbook 1 — basic-secure.yml</h2>
<p>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.</p>
<pre><code class="language-yaml">---
- name: Harden Ubuntu VPS Security Configuration
  hosts: vpsDemo
  gather_facts: true
  vars_prompt:
    - name: 'custom_admin_user'
      prompt: 'Enter the custom username for your main administrator account'
      private: false
  tasks:
    # 1. GENERATE RANDOM PASSWORD
    - name: Generate random password
      set_fact:
        new_admin_password: "{{ lookup('password', '/dev/null chars=ascii_letters,digits,hexdigits length=16') }}"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    - name: Display Removal Confirmation
      ansible.builtin.debug:
        msg:
          - '========================================================'
          - "SUCCESS: Account '{{ user_to_delete }}' and their sudo privileges"
          - 'have been completely purged from the server.'
          - '========================================================'
</code></pre>
<hr />
<h2>Conclusion</h2>
<p>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.</p>
<p>Blessings. 🙏</p>
<hr />
<p><em>Looking for developer templates built on TanStack, Directus, and Tailwind CSS?</em> <em>🛒</em> <a href="https://northernrangedigital.lemonsqueezy.com/"><em>northernrangedigital.lemonsqueezy.com</em></a> <em>🌐</em> <a href="https://northernrangedigital.com"><em>northernrangedigital.com</em></a></p>
]]></content:encoded></item><item><title><![CDATA[Ansible Installation and Configuration on Ubuntu ]]></title><description><![CDATA[https://www.youtube.com/watch?v=1U8ID8j_gLg

Introduction
What is Ansible?
Ansible is an automation language that can describe any IT environment, whether homelab or large-scale infrastructure. It is ]]></description><link>https://blog.northernrangedigital.com/ansible-installation-and-configuration-on-ubuntu</link><guid isPermaLink="true">https://blog.northernrangedigital.com/ansible-installation-and-configuration-on-ubuntu</guid><category><![CDATA[ansible]]></category><category><![CDATA[Workflow Automation]]></category><category><![CDATA[VPS Hosting]]></category><category><![CDATA[Ubuntu]]></category><category><![CDATA[ansible-playbook]]></category><category><![CDATA[ansible-module]]></category><category><![CDATA[ansible inventory file]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Fri, 10 Jul 2026 04:26:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/53f4a593-266d-44f1-9bc3-f92db2498ccf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a class="embed-card" href="https://www.youtube.com/watch?v=1U8ID8j_gLg">https://www.youtube.com/watch?v=1U8ID8j_gLg</a></p>

<h2>Introduction</h2>
<h3>What is Ansible?</h3>
<p>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.</p>
<p>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.</p>
<p>It only requires Ansible on the <strong>Control Node</strong> and <strong>Python 3</strong> on the <strong>Managed Node</strong>.</p>
<h3>What is the Control Node?</h3>
<p>The system that Ansible is installed on — it controls the remote machines.</p>
<h3>What is the Managed Node?</h3>
<p>The remote system or host that Ansible controls. Ansible is <strong>agentless</strong>, meaning you don't need to install Ansible on managed nodes — just Python 3.</p>
<hr />
<h2>Installing Ansible on Ubuntu (Control Node)</h2>
<pre><code class="language-bash">sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansible
</code></pre>
<blockquote>
<p><strong>Note:</strong> Ensure Python 3 is installed on your remote server. Ubuntu 24.04 LTS ships with Python 3 by default.</p>
</blockquote>
<hr />
<h2>Create the Inventory Folder and Hosts File</h2>
<p>The hosts file maps the remote machines you want to control.</p>
<p><strong>Folder structure:</strong></p>
<p>Ansible/<br />├── inventory/<br />│ └── hosts<br />└── playbooks/<br />└── apt-update.yml</p>
<p><code>inventory/hosts</code></p>
<pre><code class="language-ini">[servers]
vpsServer ansible_host=10.10.100.45
work-ToRule
10.10.45.62
</code></pre>
<p>You can give hosts an alias by pairing a name with an IP address. In the example above, <code>vpsServer</code> is an alias for <code>10.10.100.45</code>.</p>
<hr />
<h2>Your First Playbook — Update Ubuntu and Set Timezone</h2>
<p>Create <code>playbooks/apt-update.yml</code>:</p>
<pre><code class="language-yaml">- hosts: '*'
  become: true
  serial: 1
  tasks:
    - name: Set system timezone to Trinidad and Tobago time
      community.general.timezone:
        name: America/Port_of_Spain

    - name: Update apt cache
      apt:
        update_cache: yes
        cache_valid_time: 3600

    - name: Upgrade all packages to the latest version
      apt:
        upgrade: dist

    - name: Check if reboot is required
      stat:
        path: /var/run/reboot-required
      register: reboot_required_file

    - name: Reboot the server
      reboot:
        msg: 'Reboot initiated by Ansible due to package upgrades'
        connect_timeout: 5
        reboot_timeout: 300
        pre_reboot_delay: 0
        post_reboot_delay: 30
      when: reboot_required_file.stat.exists
</code></pre>
<h3>Breaking Down the Playbook</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>hosts: '*'</code></td>
<td>Target all hosts in inventory. Use an alias, DNS name, or IP to target a single host.</td>
</tr>
<tr>
<td><code>become: true</code></td>
<td>Grants Ansible sudo privileges.</td>
</tr>
<tr>
<td><code>serial: 1</code></td>
<td>Processes servers one at a time instead of all at once.</td>
</tr>
<tr>
<td><code>tasks</code></td>
<td>A list of individual actions to run on the target hosts.</td>
</tr>
</tbody></table>
<p>Each task has four parts:</p>
<ul>
<li><p><code>name</code> — a plain-text description of what the task does</p>
</li>
<li><p><strong>Collection</strong> (<code>community.general</code>) — the Ansible content bundle the module belongs to</p>
</li>
<li><p><strong>Module</strong> (<code>timezone</code>, <code>apt</code>, <code>reboot</code>) — the tool that executes the action</p>
</li>
<li><p><strong>Parameters</strong> (<code>name: America/Port_of_Spain</code>) — the specific options passed to the module</p>
</li>
</ul>
<blockquote>
<p>📚 Browse all available modules and collections at <a href="https://docs.ansible.com/projects/ansible/latest/collections/index.html">docs.ansible.com</a></p>
</blockquote>
<hr />
<h2>Running the Playbook</h2>
<h3>Step 1 — Test connectivity with a ping</h3>
<pre><code class="language-bash">ANSIBLE_HOST_KEY_CHECKING=FALSE ansible -i ./inventory/hosts vpsServer -m ping --user root --ask-pass
</code></pre>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANSIBLE_HOST_KEY_CHECKING=FALSE</code></td>
<td>Skips SSH host key verification — useful for fresh servers</td>
</tr>
<tr>
<td><code>-i ./inventory/hosts</code></td>
<td>Points to your inventory file</td>
</tr>
<tr>
<td><code>vpsServer</code></td>
<td>The target host alias</td>
</tr>
<tr>
<td><code>-m ping</code></td>
<td>Runs the ping module to check connectivity and Python availability</td>
</tr>
<tr>
<td><code>--ask-pass</code></td>
<td>Prompts for SSH password</td>
</tr>
</tbody></table>
<p>Once you get a green <strong>pong</strong> response, you're ready to run the playbook.</p>
<h3>Step 2 — Run the playbook</h3>
<pre><code class="language-bash">ansible-playbook ./playbooks/apt-update.yml --user root -e "ansible_port=22" --ask-pass --ask-become-pass -i ./inventory/hosts
</code></pre>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ansible-playbook</code></td>
<td>Runs a full automation script instead of a single ad-hoc task</td>
</tr>
<tr>
<td><code>./playbooks/apt-update.yml</code></td>
<td>Path to your playbook file</td>
</tr>
<tr>
<td><code>--user root</code></td>
<td>SSH connection username</td>
</tr>
<tr>
<td><code>-e "ansible_port=22"</code></td>
<td>Injects extra variable to force port 22</td>
</tr>
<tr>
<td><code>--ask-pass</code></td>
<td>Prompts for SSH login password</td>
</tr>
<tr>
<td><code>--ask-become-pass</code></td>
<td>Prompts for sudo password (redundant when logging in as root)</td>
</tr>
<tr>
<td><code>-i ./inventory/hosts</code></td>
<td>Points to your inventory file</td>
</tr>
</tbody></table>
<h3>Execution Flow</h3>
<ol>
<li><p>Ansible reads <code>./inventory/hosts</code> to find the target server's IP</p>
</li>
<li><p>Prompts for SSH password</p>
</li>
<li><p>Prompts for sudo password</p>
</li>
<li><p>Connects to port 22 as root</p>
</li>
<li><p>Opens <code>apt-update.yml</code> and executes each task in order</p>
</li>
</ol>
<hr />
<h2>Conclusion</h2>
<p>A big shout-out to Aldo <a href="https://dev.to/aldo_cve">@aldo_cve</a> for recommending Ansible in a previous post — it's been a great addition to my server management workflow.</p>
<p>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.</p>
<hr />
<p><em>Looking for developer templates built on TanStack, Directus, and Tailwind CSS? Check out my store 👇</em> <em>🛒</em> <a href="https://northernrangedigital.lemonsqueezy.com/"><em>northernrangedigital.lemonsqueezy.com</em></a></p>
]]></content:encoded></item><item><title><![CDATA[How to Secure a VPS: The Complete Ubuntu Hardening Guide]]></title><description><![CDATA[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 b]]></description><link>https://blog.northernrangedigital.com/how-to-secure-a-vps-the-complete-ubuntu-hardening-guide</link><guid isPermaLink="true">https://blog.northernrangedigital.com/how-to-secure-a-vps-the-complete-ubuntu-hardening-guide</guid><category><![CDATA[Secure]]></category><category><![CDATA[vps]]></category><category><![CDATA[Security]]></category><category><![CDATA[fail2ban]]></category><category><![CDATA[ufwfirewall]]></category><category><![CDATA[ssh]]></category><category><![CDATA[disable-root]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Thu, 02 Jul 2026 05:49:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/f3dc849a-4d0e-44fc-923c-d3e8d5d9742e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>Prefer to follow along on video?</p>
<p><a href="https://www.youtube.com/watch?v=bv9OtbRqLMo">https://www.youtube.com/watch?v=bv9OtbRqLMo</a></p>
<h2>1. Stop Using the Root User</h2>
<p>Every Linux server in the world ships with an account named <code>root</code>. 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.</p>
<p>A few other reasons to move off root:</p>
<ul>
<li><p><strong>No safety net.</strong> Root executes destructive commands instantly, no confirmation. Run <code>rm -rf /</code> as root and it's gone. A standard user needs <code>sudo</code>, which at least forces a pause and a password prompt.</p>
</li>
<li><p><strong>Bots target root specifically.</strong> 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.</p>
</li>
<li><p><strong>No accountability.</strong> 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 <code>/var/log/auth.log</code>, so every command is tied to a specific user.</p>
</li>
</ul>
<h3>Create a sudo user</h3>
<pre><code class="language-bash">adduser your_username
usermod -aG sudo your_username
</code></pre>
<p><code>-aG</code> breaks down into two flags: <code>-G</code> adds the user to the group that follows (<code>sudo</code>), and <code>-a</code> (append) makes sure the user is <em>added</em> to that group rather than having all their other group memberships wiped out. Together, <code>-aG</code> says: add this user to <code>sudo</code>, keep everything else as-is.</p>
<p>Verify it worked:</p>
<pre><code class="language-bash">groups your_username
</code></pre>
<h3>Lock down root</h3>
<p>Log out of root and back in as your new sudo user first, then:</p>
<pre><code class="language-bash"># Lock the root password
sudo passwd -l root

# Disable root login over SSH
sudo nano /etc/ssh/sshd_config
# set: PermitRootLogin no
sudo systemctl restart sshd

# Confirm root is locked
sudo passwd -S root
</code></pre>
<h2>2. Set Up a Firewall (UFW)</h2>
<p>A firewall closes off everything you're not explicitly using. On a fresh VPS, that means:</p>
<ul>
<li><p>Blocking brute-force attempts on ports you don't need exposed</p>
</li>
<li><p>Keeping internal-only services (databases, admin tools) off the public internet</p>
</li>
<li><p>Restricting sensitive ports like SSH to specific IPs, if needed</p>
</li>
<li><p>Dropping unexpected traffic, which softens basic DoS attempts</p>
</li>
<li><p>Closing the door on any hidden vulnerability in something you're running</p>
</li>
</ul>
<p>Ubuntu ships with UFW (Uncomplicated Firewall), which blocks all incoming traffic by default and only opens what you explicitly allow:</p>
<pre><code class="language-bash"># Allow SSH first so you don't lock yourself out
sudo ufw allow OpenSSH
# or: sudo ufw allow 22/tcp

# Allow web traffic if you're hosting a site
sudo ufw allow http
sudo ufw allow https

# Turn it on
sudo ufw enable

# Check status
sudo ufw status verbose
</code></pre>
<h2>3. Change the Default SSH Port</h2>
<p>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.</p>
<pre><code class="language-bash">sudo nano /etc/ssh/sshd_config
# uncomment #Port 22 and change it:
# Port 2222

sudo ufw allow 2222/tcp
sudo ufw reload
sudo systemctl restart sshd
</code></pre>
<p>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.</p>
<h2>4. Install Fail2Ban</h2>
<p>Fail2Ban watches your logs for repeated failed login attempts and temporarily bans the offending IP at the firewall level.</p>
<p><strong>Why it's worth running:</strong></p>
<ul>
<li><p>Free, open-source, and quick to set up</p>
</li>
<li><p>Highly configurable — ban duration, whitelisted IPs, which services to watch</p>
</li>
<li><p>Bans happen at the firewall, so malicious traffic doesn't eat server resources</p>
</li>
<li><p>Can integrate with notifications for real-time alerts</p>
</li>
</ul>
<p><strong>Where it falls short:</strong></p>
<ul>
<li><p>Reactive, not preventive — it only acts after a set number of failed attempts have already happened</p>
</li>
<li><p>Can lock out legitimate users who fat-finger a password a few times in a row</p>
</li>
<li><p>Weak against distributed attacks, since it bans by IP and botnets rotate through thousands of them</p>
</li>
<li><p>Vulnerable to IP spoofing</p>
</li>
<li><p>Can conflict with Docker's iptables rules on a Docker host, causing bans to fail or hit the wrong container</p>
</li>
</ul>
<p><strong>Install it:</strong></p>
<pre><code class="language-bash">sudo apt update
sudo apt install fail2ban -y
sudo systemctl start fail2ban
sudo systemctl enable fail2ban
</code></pre>
<p>(The <code>-y</code> flag auto-confirms every prompt during install — only use it once you're already certain about the package.)</p>
<h2>Wrap-Up</h2>
<p>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.</p>
<p>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.</p>
]]></content:encoded></item><item><title><![CDATA[What is Redis? The In-Memory Data Store That Makes Your App Faster]]></title><description><![CDATA[🎬 This article is a companion to my YouTube video. Watch it here:

https://www.youtube.com/watch?v=e3KNJr1ATv8


Introduction
In this video we are going to talk about Redis — what it is, what it does]]></description><link>https://blog.northernrangedigital.com/what-is-redis-the-in-memory-data-store-that-makes-your-app-faster</link><guid isPermaLink="true">https://blog.northernrangedigital.com/what-is-redis-the-in-memory-data-store-that-makes-your-app-faster</guid><category><![CDATA[Redis]]></category><category><![CDATA[caching]]></category><category><![CDATA[backend]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[webdev]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Wed, 10 Jun 2026 03:54:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/a9d15c0c-3a8d-4e84-8764-c2872089b1aa.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>🎬 This article is a companion to my YouTube video. Watch it here:</p>
</blockquote>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=e3KNJr1ATv8">https://www.youtube.com/watch?v=e3KNJr1ATv8</a></p>

<hr />
<h2>Introduction</h2>
<p>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.</p>
<hr />
<h2>What is Redis?</h2>
<p>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.</p>
<p>Redis is most commonly used as a cache, a session store, a message broker, and a real-time data store.</p>
<hr />
<h2>What is Caching?</h2>
<p>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.</p>
<p>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.</p>
<p>Think of it like a shortcut. Instead of driving the long route to the database every time, you take the shortcut through Redis.</p>
<hr />
<h2>What Does Redis Do?</h2>
<h3>Caching</h3>
<p>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.</p>
<h3>Session storage</h3>
<p>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.</p>
<h3>Rate limiting</h3>
<p>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.</p>
<h3>Message queues and pub/sub</h3>
<p>Redis supports publish/subscribe messaging and message queues. Applications can publish messages to a channel and subscribers receive them in real time.</p>
<h3>Leader boards and counters</h3>
<p>Redis sorted sets make it trivial to implement leader boards, counters, and real-time analytics.</p>
<hr />
<h2>Why Directus Uses Redis</h2>
<p>Directus uses Redis for two primary purposes.</p>
<p>First, as a cache layer. Directus caches API responses, schema information, and permission look-ups in Redis. This dramatically reduces database load and speeds up API response times.</p>
<p>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.</p>
<p>For a single Directus instance Redis is optional but recommended. For multiple instances it is required.</p>
<hr />
<h2>Why I Chose Redis</h2>
<p>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.</p>
<hr />
<h2>Conclusion</h2>
<p>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.</p>
<p>In an upcoming video we will deploy Redis alongside Directus and PostgreSQL on our VPS using Coolify.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://redis.io/">Redis Website</a></p>
</li>
<li><p><a href="https://redis.io/docs">Redis Documentation</a></p>
</li>
<li><p><a href="https://github.com/redis/redis">Redis GitHub</a></p>
</li>
</ul>
<hr />
<p>🔔 <em>Subscribe to my YouTube channel for the full series on building a modern web app back end from scratch.</em></p>
]]></content:encoded></item><item><title><![CDATA[What is PostgreSQL]]></title><description><![CDATA[🎬 This article is a companion to my YouTube video. Watch it here:

https://www.youtube.com/watch?v=qPm6Fa2G_gQ


Introduction
In this video we are going to talk about PostgreSQL — what it is, why it ]]></description><link>https://blog.northernrangedigital.com/what-is-postgresql</link><guid isPermaLink="true">https://blog.northernrangedigital.com/what-is-postgresql</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[webdev]]></category><category><![CDATA[backend developments]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Databases]]></category><category><![CDATA[SQL]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 26 May 2026 15:33:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/4741bba0-a863-4eb6-af2f-cd6a630bbcb8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>🎬 This article is a companion to my YouTube video. Watch it here:</p>
</blockquote>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=qPm6Fa2G_gQ">https://www.youtube.com/watch?v=qPm6Fa2G_gQ</a></p>

<hr />
<h2>Introduction</h2>
<p>In this video we are going to talk about PostgreSQL — what it is, why it is one of the most popular databases in the world, and why it is the database I use to power my web applications.</p>
<hr />
<h2>What is PostgreSQL?</h2>
<p>PostgreSQL — often called Postgres — is a free, open-source relational database management system. It has been in active development for over 35 years and is widely considered one of the most advanced, stable and feature-rich databases available.</p>
<p>A relational database stores data in tables — rows and columns — and uses SQL to query and manipulate that data. If you have ever worked with a spreadsheet, you already understand the basic concept.</p>
<hr />
<h2>Why PostgreSQL?</h2>
<h3>ACID compliance</h3>
<p>PostgreSQL is fully ACID compliant — Atomicity, Consistency, Isolation, Durability. Transactions either complete fully or not at all. If something goes wrong mid-transaction, the database rolls back to the previous state. For financial data, orders, user accounts — anything where data integrity matters — this is critical.</p>
<h3>Advanced data types</h3>
<p>PostgreSQL supports JSON and JSONB for document storage, arrays, UUID, geometric types, full-text search and more. You get the flexibility of a document database with the reliability of a relational database.</p>
<h3>Excellent performance</h3>
<p>PostgreSQL handles complex queries, large datasets and high concurrency extremely well. It has a sophisticated query planner and optimizer that makes even complex joins and aggregations fast.</p>
<h3>Extensibility</h3>
<p>PostgreSQL is highly extensible. PostGIS for geospatial data, pgvector for AI embeddings, and TimescaleDB for time series data are just a few examples of powerful extensions available.</p>
<h3>Open source and free</h3>
<p>PostgreSQL is completely free and open source with no licensing costs. There is no enterprise tier required to access advanced features.</p>
<h3>Widely supported</h3>
<p>Almost every major framework, ORM, and tool supports PostgreSQL. Directus, Prisma, Drizzle, Sequelize, Django, Rails — they all work with PostgreSQL out of the box.</p>
<hr />
<h2>PostgreSQL vs MySQL</h2>
<p>PostgreSQL is more standards compliant and supports more advanced features out of the box. MySQL is slightly simpler to set up and has historically been faster for simple read-heavy workloads. For modern web applications with complex data requirements, PostgreSQL is generally the better choice.</p>
<hr />
<h2>Why I Chose PostgreSQL</h2>
<p>PostgreSQL is the database that Directus recommends and works best with. It gives me full ACID compliance, advanced data types, and excellent performance — everything I need for production web applications.</p>
<hr />
<h2>Conclusion</h2>
<p>PostgreSQL is a battle-tested, feature-rich, open-source relational database that powers some of the world's largest applications. For modern web development it is one of the safest and most capable choices available.</p>
<p>In an upcoming video we will deploy PostgreSQL alongside Directus on our VPS using Coolify.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://www.postgresql.org/">PostgreSQL Website</a></p>
</li>
<li><p><a href="https://www.postgresql.org/docs">PostgreSQL Documentation</a></p>
</li>
</ul>
<hr />
<p>🔔 <em>Subscribe to my YouTube channel for the full series on building a modern web app back end from scratch.</em></p>
]]></content:encoded></item><item><title><![CDATA[What is Directus?]]></title><description><![CDATA[🎬 This article is a companion to my YouTube video. Watch it here:
https://www.youtube.com/watch?v=83OJERORAj8


Introduction
Before we get into setting up Coolify, I want to make sure you understand ]]></description><link>https://blog.northernrangedigital.com/what-is-directus</link><guid isPermaLink="true">https://blog.northernrangedigital.com/what-is-directus</guid><category><![CDATA[webdev]]></category><category><![CDATA[directus]]></category><category><![CDATA[headless cms]]></category><category><![CDATA[self-hosted]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Sun, 17 May 2026 22:22:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/37f6d8a2-94e7-4b55-92b7-9f191b3d1605.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>🎬 This article is a companion to my YouTube video. Watch it here:</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=83OJERORAj8">https://www.youtube.com/watch?v=83OJERORAj8</a></p>

<hr />
<h2>Introduction</h2>
<p>Before we get into setting up Coolify, I want to make sure you understand the tools we will be deploying. In this video we are going to talk about Directus — what it is, what it does, and why I chose it as the backbone of my back-end stack.</p>
<hr />
<h2>What is Directus?</h2>
<p>Directus is a free, open-source, headless CMS and data platform. But before we go any further, let me explain what headless CMS means.</p>
<p>A traditional CMS — like WordPress — couples the content management system with the front end. The way your content is stored and the way it is displayed are tightly linked. You are locked into how WordPress presents your content.</p>
<p>A headless CMS separates the two. Directus manages and stores your data, and exposes it through a REST API or GraphQL endpoint. Your front end — whether it is a React app, a mobile app, or anything else — consumes that API and decides how to display the content. The CMS has no head — meaning no fixed front end — hence the name headless.</p>
<hr />
<h2>What Makes Directus Different?</h2>
<h3>It works with your existing database</h3>
<p>Most headless CMS platforms use their own proprietary data storage. Directus sits on top of a standard relational database — PostgreSQL, MySQL, SQLite and more. Your data is stored in plain database tables that you own and can access directly. You are never locked into a proprietary format.</p>
<h3>Auto-generated API</h3>
<p>When you create a collection in Directus — think of a collection like a database table — it automatically generates a full REST API and GraphQL endpoint for that collection. No code required. You get full CRUD operations out of the box — create, read, update and delete.</p>
<h3>Powerful admin dashboard</h3>
<p>Directus comes with a beautiful, fully featured admin dashboard out of the box. Your clients or content editors can manage content without ever touching code. You can customize the dashboard with custom fields, relationships, file uploads, translations and more.</p>
<h3>Role-based access control</h3>
<p>Directus has a very granular permissions system. You can control exactly who can read, create, update or delete data at the collection level, the field level, and even the row level. This makes it suitable for multi-tenant applications and complex permission requirements.</p>
<h3>File management</h3>
<p>Directus includes a full file and asset management system. You can upload images, videos, documents and more. It supports on-the-fly image transformations — resize, crop, format conversion — all through URL parameters.</p>
<h3>Flows — built-in automation</h3>
<p>Directus has a built-in automation system called Flows. You can build workflows triggered by events — like sending an email when a new order is created, or updating a related record when a status changes — all without writing code.</p>
<h3>Open source and self-hostable</h3>
<p>Directus is completely open source. You can self-host it on your own server, which means your data stays on your infrastructure. There is also a cloud hosted option if you prefer a managed solution.</p>
<hr />
<h2>Why I Chose Directus</h2>
<p>I chose Directus for several reasons.</p>
<p>First, it works with PostgreSQL out of the box. I wanted a standard relational database that I own and control, not a proprietary data store.</p>
<p>Second, the auto-generated API saves me an enormous amount of time. Instead of building CRUD endpoints for every collection, Directus handles that automatically. I focus on building features, not boilerplate API code.</p>
<p>Third, the admin dashboard is genuinely excellent. My clients can manage their own content without any technical knowledge. I do not have to build a custom admin interface for every project.</p>
<p>Fourth, it is self-hostable. My data stays on my server. I control the infrastructure, the costs, and the data.</p>
<hr />
<h2>What are the Limitations?</h2>
<ul>
<li><p><strong>Not a traditional backend framework</strong> — complex business logic may require supplementing with custom code or choosing a different solution.</p>
</li>
<li><p><strong>Licensing costs at scale</strong> — Directus is free and open source for projects generating up to <strong>$5 million USD</strong> in annual revenue. Beyond that threshold a commercial license is required. For the vast majority of startups, small teams and indie developers this limit will never be reached, making it effectively free for most use cases.</p>
</li>
<li><p><strong>Can be overkill for simple projects</strong> — a basic blog may not need all of Directus's features.</p>
</li>
</ul>
<hr />
<h2>Conclusion</h2>
<p>Directus is a powerful, flexible, open-source headless CMS that sits on top of your own database and gives you a full API and admin dashboard out of the box. For developers building modern web applications who want to own their data and move fast without writing boilerplate, it is one of the best tools available.</p>
<p>In an upcoming video we will deploy Directus on our VPS using Coolify and connect it to our TanStack Start front end.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://directus.io">Directus Website</a></p>
</li>
<li><p><a href="https://docs.directus.io">Directus Documentation</a></p>
</li>
<li><p><a href="https://github.com/directus/directus">Directus GitHub</a></p>
</li>
</ul>
<hr />
<p><em>🔔 Subscribe to my YouTube channel for the full series on building a modern web app back end from scratch.</em></p>
]]></content:encoded></item><item><title><![CDATA[ What is Coolify and Why Would You Use It? ]]></title><description><![CDATA[🎬 This article is a companion to my YouTube video. Watch it here:

https://www.youtube.com/watch?v=oFmJYMk1iCg


Introduction
In the last video, we talked about the VPS and why it is a compelling opt]]></description><link>https://blog.northernrangedigital.com/what-is-coolify-and-why-would-you-use-it</link><guid isPermaLink="true">https://blog.northernrangedigital.com/what-is-coolify-and-why-would-you-use-it</guid><category><![CDATA[coolify]]></category><category><![CDATA[vps]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Thu, 14 May 2026 03:46:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/145a1ad9-02ee-4acb-a1f7-8b9d872f0dc0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<blockquote>
<p>🎬 This article is a companion to my YouTube video. Watch it here:</p>
</blockquote>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=oFmJYMk1iCg">https://www.youtube.com/watch?v=oFmJYMk1iCg</a></p>

<hr />
<h2>Introduction</h2>
<p>In the last video, we talked about the VPS and why it is a compelling option for hosting your web applications. I mentioned a tool called Coolify that makes managing a VPS significantly easier. In this video, we are going to dive deeper into what Coolify actually is, what it does, and why I think it is one of the best tools available for developers and small teams who want the power of a VPS without the complexity of managing one from scratch.</p>
<hr />
<h2>What is Coolify?</h2>
<p>Coolify is a free, open-source, self-hostable platform as a service — or PaaS. Think of it as your own personal Heroku or Render, but running on your own server. This means you own your infrastructure, your data, and your costs.</p>
<p>The best way to understand Coolify is to compare it to the alternatives. Platforms like Heroku, Render, and Railway are fully managed PaaS solutions. They abstract away all the server complexity — you push your code and it runs. The trade-off is cost and control. As your app scales, the bills grow quickly and you have limited control over the underlying infrastructure.</p>
<p>Coolify gives you the same developer experience — push your code and it deploys — but on a VPS that you control. You get the simplicity of a managed platform with the economics and control of a VPS.</p>
<hr />
<h2>What Does Coolify Do?</h2>
<h3>Git Integration</h3>
<p>Connect your GitHub, GitLab, or Bitbucket repository and Coolify will automatically deploy your app every time you push to your main branch. No manual deployments, no SSH commands — just push your code and it is live.</p>
<h3>Dockerized Deployments</h3>
<p>Every application Coolify deploys runs in a Docker container. This means your apps are isolated, portable, and consistent across environments. You do not need to know Docker deeply to use Coolify — it handles the containerization for you.</p>
<h3>Automatic HTTPS</h3>
<p>Coolify integrates with Let's Encrypt to automatically provision and renew SSL certificates for all your applications. Every app gets HTTPS out of the box with zero configuration on your part.</p>
<h3>Built-in Reverse Proxy</h3>
<p>Coolify uses Traefik as its built-in reverse proxy and web server. It automatically routes traffic to the right application based on the domain name. You can run multiple applications on the same VPS and Coolify handles the routing between them.</p>
<h3>Database Management</h3>
<p>Coolify can deploy and manage databases alongside your applications — PostgreSQL, MySQL, MongoDB, Redis and more. You can spin up a database with a few clicks and connect it to your application without any manual configuration.</p>
<h3>Environment Variables</h3>
<p>Manage your environment variables securely through the Coolify dashboard. No more manually editing .env files on the server.</p>
<h3>Monitoring and Logs</h3>
<p>Coolify provides basic monitoring and real-time log streaming for all your applications directly from the dashboard. You can see what your app is doing without SSH-ing into the server.</p>
<h3>Backups</h3>
<p>Coolify supports automated database backups to S3-compatible storage. Your data is protected without any manual backup scripts.</p>
<hr />
<h2>Why Would You Use Coolify?</h2>
<h3>You want the economics of a VPS without the complexity</h3>
<p>A \(6 to \)10 per month VPS with Coolify can run multiple applications that would cost hundreds of dollars per month on Heroku, Render, or Railway. For a startup or indie developer this is a significant saving.</p>
<h3>You want full control over your infrastructure</h3>
<p>With Coolify you own everything. Your data stays on your server. You choose your hosting provider. You are not locked into any platform's pricing or terms of service.</p>
<h3>You want a great developer experience</h3>
<p>Coolify's dashboard is clean and intuitive. Deploying an application is genuinely just a few clicks. It does not feel like managing a server — it feels like using a modern PaaS.</p>
<h3>You are running multiple projects</h3>
<p>One VPS with Coolify can host multiple applications, multiple databases, and multiple domains. Instead of paying for separate hosting for each project, you consolidate everything onto one server.</p>
<hr />
<h2>What Are the Limitations?</h2>
<ul>
<li><p><strong>You are responsible for your server</strong> — if your VPS goes down, your apps go down.</p>
</li>
<li><p><strong>Some configuration is still required</strong> — especially for custom setups, firewalls, and advanced networking.</p>
</li>
<li><p><strong>It is self-hosted</strong> — meaning you need to keep Coolify itself updated and maintained.</p>
</li>
<li><p><strong>Not ideal for very large scale</strong> — for enterprise applications with massive traffic you may need dedicated infrastructure beyond a single VPS.</p>
</li>
</ul>
<hr />
<h2>How Do You Get Started?</h2>
<p>Getting Coolify up and running is surprisingly straightforward. In the next video I will walk you through the complete setup — from provisioning a VPS to having Coolify installed and your first application deployed.</p>
<p>All you need to get started is:</p>
<ul>
<li><p>A VPS with at least <strong>2GB RAM</strong> and <strong>2 CPU cores</strong></p>
</li>
<li><p>A domain name</p>
</li>
<li><p>About 30 minutes of your time</p>
</li>
</ul>
<hr />
<h2>Conclusion</h2>
<p>Coolify bridges the gap between the simplicity of managed platforms and the power and economics of a VPS. For developers and small teams who want to own their infrastructure without being overwhelmed by server management, it is genuinely one of the best tools available right now.</p>
<p>In the next video we will get our hands dirty and set up Coolify from scratch. See you there.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://coolify.io/">Coolify Website</a></p>
</li>
<li><p><a href="https://coolify.io/docs">Coolify Documentation</a></p>
</li>
<li><p><a href="https://github.com/coollabsio/coolify">Coolify GitHub</a></p>
</li>
</ul>
<hr />
<p>🔔 <em>Subscribe to my YouTube channel for the full series on building a modern web app back end from scratch.</em></p>
]]></content:encoded></item><item><title><![CDATA[Back-End Web Development — VPS vs Vercel vs Netlify subtitle: The tools I use to power my web applications and why I chose them.]]></title><description><![CDATA[🎬 This article is a companion to my YouTube video. Watch it here:

https://www.youtube.com/watch?v=jxMMyRrWcyk


Introduction
Every great web application with millions of users has an even greater ba]]></description><link>https://blog.northernrangedigital.com/back-end-web-development-vps-vs-vercel-vs-netlify-subtitle-the-tools-i-use-to-power-my-web-applications-and-why-i-chose-them</link><guid isPermaLink="true">https://blog.northernrangedigital.com/back-end-web-development-vps-vs-vercel-vs-netlify-subtitle-the-tools-i-use-to-power-my-web-applications-and-why-i-chose-them</guid><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 12 May 2026 01:16:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/4f3518c6-fa54-4c05-b1d4-1ff922b6a7ac.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<hr />
<blockquote>
<p>🎬 This article is a companion to my YouTube video. Watch it here:</p>
</blockquote>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=jxMMyRrWcyk">https://www.youtube.com/watch?v=jxMMyRrWcyk</a></p>

<hr />
<h2>Introduction</h2>
<p>Every great web application with millions of users has an even greater back end — and it has to be. Accessing your app over a prolonged period of time will test the integrity, availability, and speed of your back end. Backend technology can be inexpensive, or it can cost you thousands of dollars. So what's the deal here?</p>
<p>Let me warn you that this channel is highly opinionated. I am sharing the tools that I use and how I use them.</p>
<p>For my back end that powers my web apps, I use <strong>Directus</strong> for my headless CMS, supported by a <strong>PostgreSQL</strong> and <strong>Redis</strong> database, hosted on a <strong>VPS</strong>, and managed by <strong>Coolify</strong>. Wow, that is a mouthful — and in this series we will explore how they all work together.</p>
<p>So let's tackle each technology one at a time, starting with the VPS.</p>
<hr />
<h2>What is a VPS?</h2>
<p>A Virtual Private Server, known as a VPS, is a virtual environment created on a physical server. Think of it like an apartment building — everyone shares the same physical structure, but each unit has its own private space, utilities, and security.</p>
<p>While multiple VPS instances share a physical server, be careful not to confuse this with shared hosting. Each VPS is allocated its own dedicated resources, which are restricted to that VPS for as long as it is active. Therefore, the performance of a VPS is not directly affected by the usage of other VPS instances, but rather by the underlying performance of the physical server itself.</p>
<hr />
<h2>What are the Benefits of a VPS?</h2>
<p>There are several benefits to consider when deciding on virtual private server hosting:</p>
<ul>
<li><p><strong>Greater control</strong> — Compared to minimum shared hosting, you have root access and can fully customize your server environment.</p>
</li>
<li><p><strong>Dedicated resources</strong> — Allocated CPU, memory, and storage help ensure consistent performance.</p>
</li>
<li><p><strong>Scalability</strong> — You can easily scale resources up or down to accommodate changing traffic and application demands.</p>
</li>
<li><p><strong>Cost-effectiveness</strong> — A VPS typically offers a balance between the affordability of shared hosting and the power of a dedicated server.</p>
</li>
<li><p><strong>Improved security</strong> — Isolation from other users on the same physical server enhances security.</p>
</li>
<li><p><strong>Choice of operating system</strong> — You can choose the operating system that best suits your needs, such as Linux or Windows.</p>
</li>
</ul>
<hr />
<h2>How Does it Stack Up Against Vercel or Netlify?</h2>
<h3>Vercel</h3>
<p>Vercel offers minimal setup with Git integration and fast deployment right out of the box. It has a free Hobby plan, but it is strictly for personal, non-commercial use. Once your app grows and needs to scale, you will need to move to a paid plan.</p>
<p>The Pro plan starts at <strong>$20 per user per month</strong>, and also covers serverless functions. Despite the name, serverless code still runs on a physical server — however, the infrastructure management, such as scaling, security patching, and provisioning, is handled entirely by the cloud provider.</p>
<p>The main factor to watch carefully is cost. Vercel uses usage-based billing, and costs can escalate quickly — especially since Turbo build machines became the default for new Pro projects in February 2026, at <strong>\(0.126 per build minute</strong>. A moderate team workflow can generate over <strong>\)400 per month</strong> in build costs alone, before bandwidth and compute charges. I have seen some alarming Vercel bills shared online by customers, which means you need to fully understand the pricing model before you commit.</p>
<h3>Netlify</h3>
<p>Netlify has historically been more suited to static sites and composable web applications. Composable applications are software systems built from independent, interchangeable modules rather than a single rigid codebase. Netlify also has Git integration, customizable build plugins, and serverless functions.</p>
<p>Netlify moved to a credit-based pricing model in September 2025, designed to simplify metered billing. As of April 2026, the Pro plan costs <strong>\(20 per month</strong> and now includes unlimited team member seats, which is an improvement over the previous per-seat model. However, teams working on active projects with real traffic can burn through their credit allocation quickly, and credit pack add-ons at <strong>\)10 per 1,500 credits</strong> mean teams can regularly spend \(40 to \)80 or more per month beyond the base subscription. Your overall control is also more limited compared to a VPS, and you will still face a monthly cost as your app grows.</p>
<h3>The VPS</h3>
<p>VPS prices can grow as your app grows, but in my experience at a slower and more predictable rate — with no surprise charges. However, if you manage a VPS yourself, it requires understanding web servers, firewalls, operating systems, caching, and more. It can get complex very quickly and may cause you to spend more time managing the server than building and scaling your app.</p>
<p>That is why some developers and business owners hire a team to handle it. Small teams may not have the budget for a dedicated infrastructure team, so they often opt for a more convenient managed solution.</p>
<hr />
<h2>Coolify — The Game Changer</h2>
<p>There is one tool that I think is worth mentioning and is a genuine game changer — and that is <strong>Coolify</strong>.</p>
<p>Coolify is a self-hostable platform that makes managing your VPS significantly easier. It offers:</p>
<ul>
<li><p>✅ Git integration</p>
</li>
<li><p>✅ Dockerized container deployments</p>
</li>
<li><p>✅ Automatic HTTPS</p>
</li>
<li><p>✅ Built-in web server with no manual configuration required</p>
</li>
</ul>
<p>This makes the VPS a very real and competitive option worth considering. If you choose a solid hosting provider and pair it with the right tools, you can make this an absolute dream to work with.</p>
<p>Now don't get me wrong — even with Coolify there are some configurations you will have to make. But I do believe a VPS is a great solution for a startup, mid-size, or enterprise scaled app with the right tools.</p>
<hr />
<h2>Conclusion</h2>
<p>Stay with me in this series and we will definitely explore the possibilities. See you in the next video.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://cloud.google.com/learn/what-is-a-virtual-private-server">Google Cloud — What is a VPS</a></p>
</li>
<li><p><a href="https://aws.amazon.com/what-is/vps/">AWS — What is a VPS</a></p>
</li>
<li><p><a href="https://www.ibm.com/think/topics/vps">IBM — VPS</a></p>
</li>
<li><p><a href="https://www.scalahosting.com/blog/what-is-a-vps-technical-explanation/">Scala Hosting — VPS Explained</a></p>
</li>
<li><p><a href="https://www.dreamhost.com/blog/beginners-guide-vps/">DreamHost — Beginner's Guide to VPS</a></p>
</li>
<li><p><a href="https://vercel.com/pricing">Vercel Pricing</a></p>
</li>
<li><p><a href="https://www.netlify.com/pricing/">Netlify Pricing</a></p>
</li>
<li><p><a href="https://bejamas.com/blog/self-hosting-vs-vercel-and-netlify-which-solution-is-right">Bejamas — Self Hosting vs Vercel and Netlify</a></p>
</li>
</ul>
<hr />
<p><em>🔔 Subscribe to my YouTube channel for the full series on building a modern web app back end from scratch.</em></p>
]]></content:encoded></item><item><title><![CDATA[Connecting TanStack Start to Directus with the SDK ]]></title><description><![CDATA[If you're using Directus as your headless CMS and TanStack Start for your frontend, you don't need to write manual fetch calls or build your own auth headers. The Directus SDK handles all of it cleanl]]></description><link>https://blog.northernrangedigital.com/connecting-tanstack-start-to-directus-with-the-sdk</link><guid isPermaLink="true">https://blog.northernrangedigital.com/connecting-tanstack-start-to-directus-with-the-sdk</guid><category><![CDATA[tanstack]]></category><category><![CDATA[Directus-sdk]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 31 Mar 2026 01:54:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/0aa7460a-a3d3-4a32-9b54-1d95133c9ea4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you're using Directus as your headless CMS and TanStack Start for your frontend, you don't need to write manual fetch calls or build your own auth headers. The Directus SDK handles all of it cleanly.</p>
<p>Here's how I structure a single <code>directus.ts</code> file that covers authentication, typed data fetching, filtering, CRUD operations and file uploads.</p>
<h2>Installing the SDK</h2>
<pre><code class="language-bash">npm install @directus/sdk
</code></pre>
<h2>Setting Up the Client</h2>
<p>Create the client once and export it. Passing your schema type as a generic is what unlocks end-to-end type safety across every request.</p>
<pre><code class="language-typescript">import type { Product, Navigation, CartItem, Order } from '@/types';
import {
  authentication,
  createDirectus,
  rest,
  readItems,
  createItem,
  updateItem,
  deleteItem,
  readMe,
  updateMe,
  deleteUser,
  uploadFiles,
  registerUser as registerUserDirectus,
} from '@directus/sdk';

const directusUrl =
  import.meta.env.VITE_DIRECTUS_URL ??
  process.env.VITE_DIRECTUS_URL ??
  'https://your-directus-url.com';

const directus = createDirectus(directusUrl)
  .with(authentication('session', { credentials: 'include' }))
  .with(rest({ credentials: 'include' }));
</code></pre>
<p>Using <code>authentication('session')</code> with <code>credentials: 'include'</code> means cookies are handled automatically — no manual token management needed.</p>
<h2>Fetching Data with Full Type Safety</h2>
<p>Each collection gets its own exported async function with a typed return value.</p>
<pre><code class="language-typescript">export async function getProducts(): Promise&lt;Product[]&gt; {
  const items = await directus.request(readItems('products'));
  return items as Product[];
}
</code></pre>
<h2>Filtering is First-Class</h2>
<p>The SDK's filter syntax maps directly to Directus's query engine — no raw query strings, no URL building.</p>
<pre><code class="language-typescript">export async function getProductsByCategory(
  category: string
): Promise&lt;Product[]&gt; {
  const items = await directus.request(
    readItems('products', {
      filter: { category: { _eq: category } },
    })
  );
  return items as Product[];
}
</code></pre>
<h2>What Else is Covered</h2>
<p>The same client and pattern covers the full CRUD surface:</p>
<ul>
<li><p><code>readItems</code> — fetch collections</p>
</li>
<li><p><code>createItem</code> — insert records</p>
</li>
<li><p><code>updateItem</code> — update records</p>
</li>
<li><p><code>deleteItem</code> — delete records</p>
</li>
<li><p><code>uploadFiles</code> — handle file uploads</p>
</li>
<li><p><code>readMe</code> / <code>updateMe</code> / <code>deleteUser</code> — user profile management</p>
</li>
<li><p><code>registerUser</code> — user registration</p>
</li>
</ul>
<p>All importable directly from <code>@directus/sdk</code> — the SDK provides typed functions and you bring your own collection types for end-to-end type safety.</p>
<h2>Using it in a TanStack Start Loader</h2>
<p>TanStack Start's file-based routing and loader pattern pairs perfectly with this setup. Data is fetched server-side and ready before the component renders.</p>
<pre><code class="language-typescript">export const Route = createFileRoute('/products')({
  loader: () =&gt; getProducts()
});
</code></pre>
<p>No boilerplate, no custom wrappers, no type assertions. One file, full coverage.</p>
<hr />
<p>If you're evaluating Directus as a headless CMS for a TanStack Start project this setup gets you up and running quickly with a clean, maintainable data layer from day one.</p>
]]></content:encoded></item><item><title><![CDATA[Why I Chose Directus as My Backend]]></title><description><![CDATA[When I started building the Demo Store — a full stack e-commerce app with TanStack Start — I needed a backend that could handle data, authentication, file storage, and an admin interface without stitc]]></description><link>https://blog.northernrangedigital.com/why-i-chose-directus-as-my-backend</link><guid isPermaLink="true">https://blog.northernrangedigital.com/why-i-chose-directus-as-my-backend</guid><category><![CDATA[directus]]></category><category><![CDATA[Docker compose]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 31 Mar 2026 01:44:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/2c088656-5985-46b6-97a8-69780af38e4d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When I started building the <a href="https://demostore.northernrangedigital.com">Demo Store</a> — a full stack e-commerce app with TanStack Start — I needed a backend that could handle data, authentication, file storage, and an admin interface without stitching together multiple services.</p>
<p>Directus ticked every box. Here's why I chose it, how I set it up, and what it looks like in practice.</p>
<h2>How Directus Compares to Other CMS Options</h2>
<p>There are plenty of headless CMS options out there. Here's how Directus stands out:</p>
<table>
<thead>
<tr>
<th></th>
<th>Directus</th>
<th>Strapi</th>
<th>Contentful</th>
<th>Sanity</th>
</tr>
</thead>
<tbody><tr>
<td>Self-hostable</td>
<td>✅</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>Free to use</td>
<td>✅ under $5M revenue</td>
<td>✅ open source</td>
<td>❌ paid tiers</td>
<td>❌ paid tiers</td>
</tr>
<tr>
<td>SQL DB support</td>
<td>✅ any existing SQL DB</td>
<td>✅ PostgreSQL, MySQL, MariaDB, SQLite</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>Built-in auth</td>
<td>✅</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>REST + GraphQL</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td>Admin dashboard</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
</tr>
</tbody></table>
<p>Strapi is a strong alternative and supports multiple SQL databases too. The key difference for me was that Directus connects directly to <strong>any existing database</strong> — you point it at a database you already have and it works. There's no schema migration system to manage and no framework-specific setup. Contentful and Sanity are cloud-only, which means you don't own your data and you're subject to their pricing as you scale.</p>
<h2>Setting Up Directus with Docker Compose</h2>
<p>I run Directus on a VPS managed by Coolify. The entire setup lives in a single Docker Compose file. Here's the full config I use for the demo store:</p>
<pre><code class="language-yaml">services:
  directus:
    image: 'directus/directus:11.15.4'
    ports:
      - '8055:8055'
    volumes:
      - './uploads:/directus/uploads'
      - './extensions:/directus/extensions'
    environment:
      SECRET: '${SECRET}'
      MARKETPLACE_TRUST: all

      # Database
      DB_CLIENT: pg
      DB_HOST: '${DB_HOST}'
      DB_PORT: '5432'
      DB_DATABASE: '${DB_DATABASE}'
      DB_USER: '${DB_USER}'
      DB_PASSWORD: '${DB_PASSWORD}'

      # Redis Cache
      CACHE_ENABLED: 'false'
      CACHE_AUTO_PURGE: 'true'
      CACHE_STORE: redis
      REDIS: '${REDIS_URL}'

      # CORS
      CORS_ENABLED: 'true'
      CORS_ORIGIN: '${CORS_ORIGIN}'
      CORS_CREDENTIALS: 'true'

      # Email via Resend SMTP
      EMAIL_TRANSPORT: smtp
      EMAIL_SMTP_HOST: smtp.resend.com
      EMAIL_SMTP_PORT: 465
      EMAIL_SMTP_USER: resend
      EMAIL_SMTP_PASSWORD: '${EMAIL_SMTP_PASSWORD}'
      EMAIL_SMTP_SECURE: 'true'
      EMAIL_FROM: contact@yourdomain.com

      # Auth
      USER_REGISTER_REQUIRE_EMAIL_VERIFICATION: 'true'
      USER_REGISTER_URL_ALLOW_LIST: '${USER_REGISTER_URL_ALLOW_LIST}'
      PASSWORD_RESET_URL_ALLOW_LIST: '${PASSWORD_RESET_URL_ALLOW_LIST}'

      # Google OAuth
      AUTH_PROVIDERS: google
      AUTH_GOOGLE_DRIVER: openid
      AUTH_GOOGLE_ISSUER_URL: 'https://accounts.google.com'
      AUTH_GOOGLE_CLIENT_ID: '${AUTH_GOOGLE_CLIENT_ID}'
      AUTH_GOOGLE_CLIENT_SECRET: '${AUTH_GOOGLE_CLIENT_SECRET}'
      AUTH_GOOGLE_IDENTIFIER_KEY: email
      AUTH_GOOGLE_ALLOW_PUBLIC_REGISTRATION: 'true'
      AUTH_GOOGLE_DEFAULT_ROLE_ID: '${AUTH_GOOGLE_DEFAULT_ROLE_ID}'
      AUTH_GOOGLE_REDIRECT_ALLOW_LIST: '${AUTH_GOOGLE_REDIRECT_ALLOW_LIST}'
      AUTH_GOOGLE_MODE: session

      # Session &amp; Cookies
      SESSION_COOKIE_SECURE: 'true'
      SESSION_COOKIE_SAME_SITE: lax
      REFRESH_TOKEN_COOKIE_SECURE: 'true'
      REFRESH_TOKEN_COOKIE_SAME_SITE: lax

      # Extensions
      EXTENSIONS_AUTO_RELOAD: 'true'
      EXTENSIONS_PATH: /directus/extensions
</code></pre>
<p>A few things worth noting:</p>
<ul>
<li><p><strong>PostgreSQL</strong> is the database — <code>DB_CLIENT: pg</code> tells Directus to use it</p>
</li>
<li><p><strong>Resend</strong> handles all transactional email via SMTP — verification emails, password resets</p>
</li>
<li><p><strong>Google OAuth</strong> is configured via OpenID Connect — users can register and log in with their Google account</p>
</li>
<li><p><strong>Session cookies</strong> are secure and SameSite lax — works cleanly with TanStack Start on a separate domain</p>
</li>
<li><p>All sensitive values are environment variables — never hardcoded</p>
</li>
</ul>
<blockquote>
<p><strong>Note:</strong> In my setup PostgreSQL runs as a separate service managed independently by Coolify — that's why it's not included in this Compose file. If you prefer to keep everything together, you can add PostgreSQL directly to the same file:</p>
</blockquote>
<pre><code class="language-yaml">  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: '${DB_DATABASE}'
      POSTGRES_USER: '${DB_USER}'
      POSTGRES_PASSWORD: '${DB_PASSWORD}'
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
</code></pre>
<p>Then set <code>DB_HOST</code> to <code>postgres</code> in your Directus environment variables to point it at the service name. Either approach works — separate or combined is a matter of preference.</p>
<h2>The Admin Dashboard and Collections</h2>
<p>Once Directus is running, you access the admin dashboard at your Directus URL. This is where you build your data model visually.</p>
<p>For the demo store I created the following collections:</p>
<ul>
<li><p><strong>products</strong> — name, description (WYSIWYG), price, sale price, category, images, slug</p>
</li>
<li><p><strong>categories</strong> — name, slug, description</p>
</li>
<li><p><strong>orders</strong> — user, items, total, status</p>
</li>
<li><p><strong>order_items</strong> — order, product, quantity, price</p>
</li>
<li><p><strong>navigation</strong> — links and structure for the site nav</p>
</li>
</ul>
<p>Each collection maps directly to a PostgreSQL table. Directus generates the REST and GraphQL API automatically. No boilerplate, no migrations to write by hand.</p>
<p>The WYSIWYG editor on the <code>description</code> field outputs HTML — I cover how to render that safely in React in a later post in this series.</p>
<h2>Roles and Permissions</h2>
<p>This is where Directus really shines for production apps. Every collection and field can have granular permissions set per role.</p>
<p>For the demo store I set up three roles:</p>
<p><strong>Public</strong> — unauthenticated visitors</p>
<ul>
<li><p>Can read <code>products</code>, <code>categories</code>, <code>navigation</code></p>
</li>
<li><p>Cannot read <code>orders</code>, <code>order_items</code>, or any user data</p>
</li>
</ul>
<p><strong>Authenticated User</strong> — logged in customers</p>
<ul>
<li><p>Can read all public collections</p>
</li>
<li><p>Can read and create their own <code>orders</code> and <code>order_items</code></p>
</li>
<li><p>Can read and update their own user profile</p>
</li>
<li><p>Cannot read other users' data</p>
</li>
</ul>
<p><strong>Admin</strong> — full access</p>
<ul>
<li><p>Full CRUD on all collections</p>
</li>
<li><p>Access to the Directus admin dashboard</p>
</li>
</ul>
<p>The permissions are configured in the Directus admin panel under <strong>Settings → Roles &amp; Permissions</strong>. You set them visually — no code required. Each role gets a matrix of read, create, update, delete permissions per collection, and you can even restrict access to specific fields within a collection.</p>
<h2>What Directus Handles For the Demo Store</h2>
<ul>
<li><p>✅ All product and category data via REST API</p>
</li>
<li><p>✅ User registration with email verification</p>
</li>
<li><p>✅ Login with email/password and Google OAuth</p>
</li>
<li><p>✅ Password reset via email</p>
</li>
<li><p>✅ Order management</p>
</li>
<li><p>✅ File and image storage and serving</p>
</li>
<li><p>✅ Role-based access control</p>
</li>
<li><p>✅ Admin dashboard for managing all content</p>
</li>
</ul>
<p>All from a single self-hosted instance running on a $20/month VPS.</p>
<hr />
<p>In the next post I'll cover how I connect TanStack Start to Directus using the SDK — setting up the client, typed data fetching, and filtering collections.</p>
<p>➡️ <a href="https://blog.northernrangedigital.com/connecting-tanstack-start-to-directus-with-the-sdk">Next: Connecting TanStack Start to Directus with the SDK</a></p>
]]></content:encoded></item><item><title><![CDATA[Building a Full Stack E-Commerce App with TanStack Start and Directus ]]></title><description><![CDATA[I built a full stack e-commerce demo store — products, categories, cart sync, user authentication, email verification, protected routes, rich text content, and a complete admin dashboard to manage it ]]></description><link>https://blog.northernrangedigital.com/building-a-full-stack-e-commerce-app-with-tanstack-start-and-directus</link><guid isPermaLink="true">https://blog.northernrangedigital.com/building-a-full-stack-e-commerce-app-with-tanstack-start-and-directus</guid><category><![CDATA[tanstack]]></category><category><![CDATA[directus]]></category><category><![CDATA[shadcn]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[Zustand store]]></category><category><![CDATA[coolify vps hosting]]></category><dc:creator><![CDATA[Wade Thomas]]></dc:creator><pubDate>Tue, 31 Mar 2026 01:28:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69cb0eea9fffa74740a240a6/f7c98980-de3c-496b-ad6a-3b1ffc62f9ec.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I built a full stack e-commerce demo store — products, categories, cart sync, user authentication, email verification, protected routes, rich text content, and a complete admin dashboard to manage it all.</p>
<p>The live demo is at <a href="https://demostore.northernrangedigital.com">demostore.northernrangedigital.com</a>.</p>
<p>This series documents the entire stack I used to build it, the decisions I made along the way, and the patterns I've settled on for building full stack apps. Everything is grounded in real code from a real project.</p>
<h2>The Stack</h2>
<ul>
<li><p><strong>TanStack Start</strong> — full stack React framework with file-based routing, SSR, loaders and type-safe navigation</p>
</li>
<li><p><strong>Directus</strong> — headless CMS and backend, self-hosted on a VPS, handling data, auth, file storage and the admin dashboard</p>
</li>
<li><p><strong>PostgreSQL</strong> — the database underneath Directus</p>
</li>
<li><p><strong>Tailwind CSS v4</strong> — styling, with the Typography plugin for rich text rendering</p>
</li>
<li><p><strong>Shadcn UI</strong> — component library built on top of Tailwind</p>
</li>
<li><p><strong>Zustand</strong> — client-side state management for auth and cart</p>
</li>
<li><p><strong>Coolify</strong> — self-hosted deployment platform managing the frontend, Directus and PostgreSQL via Docker Compose</p>
</li>
<li><p><strong>Resend</strong> — email delivery for verification and password reset, configured via SMTP in Docker Compose</p>
</li>
</ul>
<h2>Why This Stack</h2>
<p>I wanted a setup that was flexible, cost-effective, and production-ready without stitching together five different paid services. Everything runs on a single VPS for around $20/month — Directus handles the backend, authentication, file storage and admin UI all in one place, and Coolify handles deployments automatically when I push code.</p>
<p>The result is a full stack that scales from a weekend project to a production app without changing tools or blowing up the infrastructure bill.</p>
<h2>What This Series Covers</h2>
<p>Each post in this series covers a specific part of the stack with real code from the demo store:</p>
<ol>
<li><p><strong>Why I chose Directus as my backend</strong> — what it offers, how it's licensed, and when it's the right fit</p>
</li>
<li><p><strong>Connecting TanStack Start to Directus with the SDK</strong> — setting up the client, typed data fetching, filtering</p>
</li>
<li><p><strong>Metadata, data loading and loading skeletons in TanStack Start</strong> — head, loader and pendingComponent</p>
</li>
<li><p><strong>Protecting routes with Zustand</strong> — auth store, hydration, layout route guards</p>
</li>
<li><p><strong>Directus auth out of the box</strong> — registration, login, email verification and password reset</p>
</li>
<li><p><strong>Rendering rich text safely</strong> — DOMPurify, html-react-parser and Tailwind Typography</p>
</li>
</ol>
<hr />
<p>If you're evaluating TanStack Start, Directus, or this kind of self-hosted setup for your next project, this series is built for you. Every post includes real code, real decisions, and honest tradeoffs.</p>
<p>Let's build.</p>
]]></content:encoded></item></channel></rss>