<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wade Thomas</title>
    <description>The latest articles on DEV Community by Wade Thomas (@wadethomastt).</description>
    <link>https://dev.to/wadethomastt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3850437%2F8d98205a-0b4e-4e70-abeb-0bb759317abf.jpg</url>
      <title>DEV Community: Wade Thomas</title>
      <link>https://dev.to/wadethomastt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wadethomastt"/>
    <language>en</language>
    <item>
      <title>Directus Basics Part 3 — User Roles &amp; Permissions</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:08:46 +0000</pubDate>
      <link>https://dev.to/wadethomastt/directus-basics-part-3-user-roles-permissions-328c</link>
      <guid>https://dev.to/wadethomastt/directus-basics-part-3-user-roles-permissions-328c</guid>
      <description>&lt;p&gt;This is part three of our Directus Basics series. In &lt;a href="https://www.youtube.com/watch?v=tJGuxqhv2SY&amp;amp;t=12s" rel="noopener noreferrer"&gt;part one&lt;/a&gt; we set up our instance, and in &lt;a href="https://www.youtube.com/watch?v=7SNrBzaxreg&amp;amp;t=108s" rel="noopener noreferrer"&gt;part two&lt;/a&gt; we covered relationships between collections. Today we're covering access control — what determines who can see and touch your data.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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




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

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

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

&lt;p&gt;Directus is a backend for developers. It can connect to any SQL database and asset storage, and it provides developer tooling — which the Directus team calls the "Data Engine" — alongside a web application that lets both developers and non-developers manipulate data and assets through a no-code interface called the "Data Studio."&lt;/p&gt;

&lt;p&gt;Directus also offers granular access control, meaning end users can only see, manipulate, and interact with the data allowed by their role and access policy — enforced consistently by both the engine and the studio. It's extensible through its own marketplace, and it's completely free, with a free license available.&lt;/p&gt;

&lt;p&gt;Directus is also known as a &lt;strong&gt;headless CMS&lt;/strong&gt; — meaning it doesn't ship with a front end attached out of the box, the way WordPress traditionally did. You can pair it with whichever front end you prefer; TanStack Start is my front end of choice.&lt;/p&gt;

&lt;p&gt;In this post, I'll self-host Directus on my Coolify-managed VPS, walk through both setup methods, and — since nothing ever works perfectly on the first try — fix the actual errors I hit along the way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📌 &lt;strong&gt;New here?&lt;/strong&gt; I'd recommend checking out my earlier posts where I set up the VPS itself and installed Coolify on it, before continuing with this one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Let's Get Started
&lt;/h2&gt;

&lt;p&gt;There are two ways to set up Directus on a Coolify-managed VPS. I'll cover the first one here.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Log in to your Coolify Dashboard
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;In the sidebar menu, go to &lt;strong&gt;Projects → +Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give your project a name and description, and press continue&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Add a Resource
&lt;/h3&gt;

&lt;p&gt;After naming your project, you should be redirected to the project's page, where you can add a resource. If that redirect doesn't happen, click &lt;strong&gt;Projects&lt;/strong&gt; in the sidebar, then click into your project, and you should see &lt;strong&gt;+Add Resource&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Click &lt;strong&gt;+Add Resource&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Under Applications, select &lt;strong&gt;Docker Compose Empty&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A blank Docker Compose file will open&lt;/li&gt;
&lt;li&gt;Go to &lt;a href="https://directus.com" rel="noopener noreferrer"&gt;directus.com&lt;/a&gt; → &lt;strong&gt;Docs&lt;/strong&gt; → &lt;strong&gt;Hosting&lt;/strong&gt; → &lt;strong&gt;Deployment&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Scroll down until you find the example Docker Compose YAML file, and copy it&lt;/li&gt;
&lt;li&gt;Paste it into your blank Coolify Compose file&lt;/li&gt;
&lt;li&gt;Find the &lt;code&gt;directus:&lt;/code&gt; service, and under it, &lt;code&gt;image:&lt;/code&gt; — update this to the version you want. As of this post, the latest version is &lt;code&gt;12.2.0&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Scroll to the end and set your own admin email and password — these become your Data Studio login credentials&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt; at the top&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Configure the Service
&lt;/h3&gt;

&lt;p&gt;You should now be on the &lt;strong&gt;Configuration&lt;/strong&gt; screen, with a &lt;strong&gt;Deploy&lt;/strong&gt; button (yellow outline arrow) in the top right.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Under &lt;strong&gt;Network&lt;/strong&gt;, check the box&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Services&lt;/strong&gt;, click &lt;strong&gt;Settings&lt;/strong&gt; on the &lt;code&gt;directus&lt;/code&gt; tab&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;Domain&lt;/strong&gt; field, enter your subdomain — e.g. &lt;code&gt;https://directus.yourdomain.com&lt;/code&gt;. &lt;strong&gt;Use &lt;code&gt;https&lt;/code&gt;&lt;/strong&gt;, so Traefik knows to issue a Let's Encrypt certificate for it&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;, then &lt;strong&gt;Back&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Edit Compose File&lt;/strong&gt; at the top, scroll down to &lt;code&gt;PUBLIC_URL&lt;/code&gt;, and set it to match your subdomain:
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;, close the modal&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Deploy&lt;/strong&gt;, then &lt;strong&gt;Confirm&lt;/strong&gt; on the popup&lt;/li&gt;
&lt;li&gt;Wait for the deployment to finish&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Launch Directus
&lt;/h3&gt;

&lt;p&gt;On the Configuration screen, click &lt;strong&gt;Links&lt;/strong&gt;, then click your new subdomain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Errors
&lt;/h2&gt;

&lt;p&gt;At this point, your browser will show two errors: a &lt;strong&gt;"Not Secure"&lt;/strong&gt; warning in the address bar, and a &lt;strong&gt;"no available server"&lt;/strong&gt; page.&lt;/p&gt;

&lt;p&gt;Back on the Coolify dashboard, you'll notice the Directus instance is showing as &lt;strong&gt;unhealthy&lt;/strong&gt;. Traefik will not route traffic to a service that's failing its health check — which is exactly what's happening here. So the first job is figuring out &lt;em&gt;why&lt;/em&gt; the health check is failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing the Problem
&lt;/h2&gt;

&lt;p&gt;SSH into your VPS to start digging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your running containers:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Install &lt;code&gt;jq&lt;/code&gt;&lt;/strong&gt;, so the JSON output we're about to read is actually readable:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check the health check failure log&lt;/strong&gt; (grab your Directus container's name from the &lt;code&gt;docker ps&lt;/code&gt; output above):&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;The first clue: &lt;code&gt;"wget: can't connect to remote host: Connection refused"&lt;/code&gt;. Whatever endpoint the health check is trying to reach, it's being actively refused — not timing out, refused. That distinction matters, and it's the thread that leads to the actual fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspecting the Docker Compose File
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;From the Coolify dashboard, select &lt;strong&gt;Projects&lt;/strong&gt; in the sidebar&lt;/li&gt;
&lt;li&gt;Click your project&lt;/li&gt;
&lt;li&gt;Click the service, then &lt;strong&gt;Edit Compose File&lt;/strong&gt; next to &lt;strong&gt;Service Stack&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the relevant section:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The problem is in the &lt;code&gt;wget&lt;/code&gt; line — specifically, &lt;code&gt;http://localhost:8055&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;localhost&lt;/code&gt; Breaks This
&lt;/h3&gt;

&lt;p&gt;Inside a Linux container, &lt;code&gt;localhost&lt;/code&gt; isn't an address — it's a hostname that has to be resolved first, and it typically maps to &lt;strong&gt;two&lt;/strong&gt; addresses at once:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;wget&lt;/code&gt; looks up &lt;code&gt;localhost&lt;/code&gt;, the system's resolver hands back both addresses, and &lt;code&gt;wget&lt;/code&gt; tries them in whatever order it receives them — which, on many Linux/Alpine base images, tends to prefer &lt;strong&gt;IPv6 first&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Checking the container's logs confirms what's actually listening:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.0.0.0&lt;/code&gt; means "listen on every IPv4 interface" — Directus never opened an IPv6 socket at all. So when &lt;code&gt;wget&lt;/code&gt; tries &lt;code&gt;::1:8055&lt;/code&gt; first, there's genuinely nothing listening there, and the OS doesn't wait around wondering — it immediately sends back a rejection (a TCP RST). That's exactly why the log shows an instant &lt;strong&gt;"Connection refused"&lt;/strong&gt; rather than a slow timeout. A timeout would mean something was reachable but not responding; a refusal means the OS said "nothing's here" right away.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;127.0.0.1&lt;/code&gt; sidesteps the whole problem, because it's already a literal IP address — no hostname lookup, no ambiguity about which protocol family to try, no chance of picking the wrong one. It goes straight to the one address where Directus is actually listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Change &lt;code&gt;localhost&lt;/code&gt; to &lt;code&gt;127.0.0.1&lt;/code&gt; in the health check:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Save the change, close the modal, and click &lt;strong&gt;Restart&lt;/strong&gt; in the top right. Wait for the restart to complete — it may look like nothing's happening for a bit, so give it a moment. Once it's finished, close the logs modal, and Directus should show as &lt;strong&gt;healthy&lt;/strong&gt;. &lt;/p&gt;

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

&lt;h2&gt;
  
  
  Logging In &amp;amp; Licensing
&lt;/h2&gt;

&lt;p&gt;Enter the credentials you set earlier in the Compose file. You'll be asked whether you have a license or want to install the Core — choose &lt;strong&gt;Core&lt;/strong&gt; for now, and follow the flow to complete sign-up.&lt;/p&gt;

&lt;p&gt;At this stage, your instance isn't fully unlocked — you'll need a license. Good news: it's completely free if your business's revenue is under $5M and your team is under 50 people.&lt;/p&gt;

&lt;p&gt;Head to &lt;a href="https://directus.com/oig" rel="noopener noreferrer"&gt;directus.com/oig&lt;/a&gt; to apply for your key. Once you have it, go to your Directus Studio, click the gear icon (Settings) in the sidebar, click &lt;strong&gt;License&lt;/strong&gt;, and add your key there to unlock all features. Your key is valid for a year.&lt;/p&gt;




&lt;p&gt;Got questions, or hit a different error setting this up? Drop a comment below — happy to help troubleshoot.&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>devops</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Coolify: The Complete Manual Setup Guide (For When the Auto-Install Script Won't Cut It)</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Thu, 23 Jul 2026 03:43:43 +0000</pubDate>
      <link>https://dev.to/wadethomastt/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-wont-cut-it-4epm</link>
      <guid>https://dev.to/wadethomastt/coolify-the-complete-manual-setup-guide-for-when-the-auto-install-script-wont-cut-it-4epm</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/jlUzYm6W-bI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Coolify's one-line install script is great — until it isn't. Right now it officially supports Ubuntu 20.04, 22.04, and 24.04 LTS. If you're running anything newer (Ubuntu's already on 26.04 LTS), the script won't work and you're left doing it manually.&lt;/p&gt;

&lt;p&gt;This is that manual walkthrough — set up in the order that fits a security-first VPS workflow rather than the order Coolify's own docs use. If you've been following along with the Ansible playbooks from earlier in this series, this picks up right where that left off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimum Hardware Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; 2 cores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; 2 GB RAM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; 30 GB free&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coolify can technically run below this, but it's not recommended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before touching Coolify itself, you'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH access to your VPS&lt;/li&gt;
&lt;li&gt;CURL installed&lt;/li&gt;
&lt;li&gt;Docker Engine installed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're reconnecting to a server you've rebuilt or re-provisioned, clear the old fingerprint first:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



&lt;p&gt;Confirm it's running and check which port it's listening on (you should have already changed this from the default 22 — see the VPS security video):&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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



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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;ca-certificates&lt;/code&gt; also get installed as part of the &lt;code&gt;apt-update&lt;/code&gt; Ansible playbook below, so this may already be handled.&lt;/p&gt;

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;If you haven't set up the Ansible inventory and playbooks from the earlier videos, do that first — this guide assumes they're already in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Docker Engine
&lt;/h2&gt;

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating a Non-Root Admin User
&lt;/h2&gt;

&lt;p&gt;Coolify's own docs assume you're using the root account. Since root login is disabled as part of the security hardening earlier in this series, we create a dedicated user with passwordless sudo instead.&lt;/p&gt;

&lt;p&gt;Run the second playbook to create that user:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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



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

&lt;/div&gt;



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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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



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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Generating Secure Environment Values
&lt;/h2&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Only run these once, on first install.&lt;/strong&gt; Changing them later can break Coolify. Back them up somewhere safe.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Disable port 8000
&lt;/h2&gt;

&lt;p&gt;Disable port 8000 on your VPS. If your hosting provider allows you to configure a firewall from your dashboard, disable it from there. To disable port 8000, simply write rules that allows the ports you want and block everything else.&lt;/p&gt;

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

&lt;p&gt;That's a full manual Coolify install on a hardened, non-root VPS — no automated script required. From here, Coolify handles the rest: connecting your Git repos, setting up applications, and managing deployments.&lt;/p&gt;

&lt;p&gt;If you hit issues with the automated script on a newer Ubuntu release, this manual path should get you unblocked. Questions or corrections welcome in the comments.&lt;/p&gt;

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

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

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

&lt;p&gt;In this post I'm sharing 3 Ansible playbooks I use to manage my VPS servers. I won't go into great detail on tasks here — if you're new to Ansible check out my previous post and video first.&lt;/p&gt;

&lt;p&gt;The three playbooks covered today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;basic-secure.yml&lt;/code&gt; — automate VPS hardening&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;add-vps-user.yml&lt;/code&gt; — semi-automated user creation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;remove-vps-user.yml&lt;/code&gt; — completely remove a user and their privileges&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;📚 &lt;strong&gt;Resources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.ansible.com" rel="noopener noreferrer"&gt;Ansible Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.ansible.com/ansible/latest/collections/index.html" rel="noopener noreferrer"&gt;Ansible Collection Index&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




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

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Name&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A plain-text description of what the task does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Collection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The Ansible content bundle the module belongs to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Module&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The tool that executes the action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Parameters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The specific options passed to the module&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Playbook 1 — basic-secure.yml
&lt;/h2&gt;

&lt;p&gt;This playbook fully hardens a fresh Ubuntu VPS in a single command. It prompts you for a custom admin username, generates a random 16-character password, configures UFW, installs Fail2Ban, and moves SSH to port 2222.&lt;br&gt;
&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Save the displayed credentials immediately&lt;/strong&gt; — the generated password is only shown once.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Playbook 2 — add-vps-user.yml
&lt;/h2&gt;

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

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

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

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

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

&lt;/div&gt;






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

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

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

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

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

&lt;/div&gt;






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

&lt;p&gt;I hope these playbooks are useful — grab them, adapt them to your environment and save yourself hours of repetitive manual work. I'll be sharing more playbooks as I build them out.&lt;/p&gt;

&lt;p&gt;Blessings. 🙏&lt;/p&gt;




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

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

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

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

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

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

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

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

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

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

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




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



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

&lt;/div&gt;



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




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

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

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

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

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

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

&lt;/div&gt;



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




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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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




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

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



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

&lt;/div&gt;



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

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

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



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

&lt;/div&gt;



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

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

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




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

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

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




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

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

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

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

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

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

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

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

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

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



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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>webdev</category>
      <category>redis</category>
      <category>caching</category>
      <category>performance</category>
    </item>
    <item>
      <title>What is PostgreSQL? The Database That Powers Modern Web Applications</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Tue, 26 May 2026 15:28:23 +0000</pubDate>
      <link>https://dev.to/wadethomastt/what-is-postgresql-the-database-that-powers-modern-web-applications-4l83</link>
      <guid>https://dev.to/wadethomastt/what-is-postgresql-the-database-that-powers-modern-web-applications-4l83</guid>
      <description>&lt;p&gt;🎬 This article is a companion to my YouTube video. Watch it here:&lt;br&gt;
  &lt;iframe src="https://www.youtube.com/embed/qPm6Fa2G_gQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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

&lt;p&gt;In this video we are going to talk about 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.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  Why PostgreSQL?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ACID compliance
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Advanced data types
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
Excellent performance&lt;br&gt;
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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extensibility
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
Open source and free&lt;br&gt;
PostgreSQL is completely free and open source with no licensing costs. There is no enterprise tier required to access advanced features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widely supported
&lt;/h3&gt;

&lt;p&gt;Almost every major framework, ORM, and tool supports PostgreSQL. Directus, Prisma, Drizzle, Sequelize, Django, Rails — they all work with PostgreSQL out of the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL vs MySQL
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Why I Chose PostgreSQL
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
In an upcoming video we will deploy PostgreSQL alongside Directus on our VPS using Coolify.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;• PostgreSQL Website
• PostgreSQL Documentation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

</description>
      <category>postgres</category>
      <category>backenddevelopment</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What is Directus? The Headless CMS That Sits On Your Own Database</title>
      <dc:creator>Wade Thomas</dc:creator>
      <pubDate>Sun, 17 May 2026 22:17:59 +0000</pubDate>
      <link>https://dev.to/wadethomastt/what-is-directus-the-headless-cms-that-sits-on-your-own-database-10d</link>
      <guid>https://dev.to/wadethomastt/what-is-directus-the-headless-cms-that-sits-on-your-own-database-10d</guid>
      <description>&lt;p&gt;🎬 This article is a companion to my YouTube video. Watch it here:&lt;/p&gt;

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




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

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




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

&lt;p&gt;Directus is a free, open-source, headless CMS and data platform. But before we go any further, let me explain what headless CMS means.&lt;/p&gt;

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

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




&lt;h2&gt;
  
  
  What Makes Directus Different?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It works with your existing database
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Auto-generated API
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Powerful admin dashboard
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Role-based access control
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  File management
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Flows — built-in automation
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Open source and self-hostable
&lt;/h3&gt;

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




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

&lt;p&gt;I chose Directus for several reasons.&lt;/p&gt;

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

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

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

&lt;p&gt;Fourth, it is self-hostable. My data stays on my server. I control the infrastructure, the costs, and the data.&lt;/p&gt;




&lt;h2&gt;
  
  
  What are the Limitations?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not a traditional backend framework&lt;/strong&gt; — complex business logic may require supplementing with custom code or choosing a different solution.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Licensing costs at scale&lt;/strong&gt; — Directus is free and open source for projects generating up to &lt;strong&gt;$5 million USD&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Can be overkill for simple projects&lt;/strong&gt; — a basic blog may not need all of Directus's features.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




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

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

&lt;p&gt;In an upcoming video we will deploy Directus on our VPS using Coolify and connect it to our TanStack Start front end.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://directus.io" rel="noopener noreferrer"&gt;Directus Website&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.directus.io" rel="noopener noreferrer"&gt;Directus Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/directus/directus" rel="noopener noreferrer"&gt;Directus GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




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

</description>
      <category>directus</category>
      <category>headlesscms</category>
      <category>opensource</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
