<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.ardotconsulting.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.ardotconsulting.com/" rel="alternate" type="text/html" /><updated>2026-09-14T22:55:59-07:00</updated><id>https://www.ardotconsulting.com/feed.xml</id><title type="html">ARDOT Consulting</title><subtitle>AI automation consulting — we help businesses implement AI solutions to automate repetitive tasks, reduce costs, and scale operations efficiently.</subtitle><entry><title type="html">How to Audit Your Business Processes for Automation Opportunities</title><link href="https://www.ardotconsulting.com/blog/2026/10/10/how-to-audit-your-business-processes-for-automation-opportunities/" rel="alternate" type="text/html" title="How to Audit Your Business Processes for Automation Opportunities" /><published>2026-10-10T00:00:00-07:00</published><updated>2026-10-10T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/10/10/how-to-audit-your-business-processes-for-automation-opportunities</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/10/10/how-to-audit-your-business-processes-for-automation-opportunities/"><![CDATA[<p>You’ve read the blog posts. You’ve heard the pitch. You know AI automation could save your team hours every week. But when you sit down to actually start, you hit a wall: <em>where do I begin?</em></p>

<p>It’s the most common question we hear from business owners. Not “how do I set up n8n” or “which LLM should I run” — those are the fun problems. The hard part is figuring out which of the hundred things your team does every day is worth automating first. Pick the wrong process and you’ll spend weeks building something that saves nine minutes a month. Pick the right one and you’ll free up a full role’s worth of capacity.</p>

<p>The answer isn’t to buy a tool and look for problems to solve with it. The answer is to audit your processes first, then automate what actually matters. Here’s how to do that systematically, without consultants, without software, and without any prior technical knowledge.</p>

<h2 id="why-you-need-an-audit-before-you-need-tools">Why You Need an Audit Before You Need Tools</h2>

<p>Most automation projects fail for the same reason: someone picks a tool, finds a process that seems automatable, builds a workflow, and discovers three months later that nobody is using it. The workflow works technically, but it solved a problem that wasn’t really a problem — or it created new problems that were worse than the original.</p>

<p>An audit prevents this. It forces you to:</p>

<ul>
  <li><strong>See your actual workflows</strong> — not what you think happens, but what actually happens when your team does the work</li>
  <li><strong>Rank by impact</strong> — so you automate the process that saves the most hours first, not the one that’s most technically interesting</li>
  <li><strong>Understand the exceptions</strong> — every process has edge cases that make automation tricky, and you need to know those before you build</li>
  <li><strong>Get team buy-in</strong> — when people see their work being analyzed respectfully (not judged), they participate honestly and support the changes</li>
</ul>

<p>The audit takes one to two weeks of part-time effort. It costs nothing. And it determines whether your automation initiative succeeds or becomes shelfware.</p>

<h2 id="step-1-inventory-what-your-team-actually-does">Step 1: Inventory What Your Team Actually Does</h2>

<p>Start by listing every recurring task your team performs. Not job descriptions — actual tasks. The granular things people do during a typical week.</p>

<p>Send a simple spreadsheet to each team member with four columns:</p>

<table>
  <thead>
    <tr>
      <th>Task Name</th>
      <th>How Often</th>
      <th>Estimated Time Per Occurrence</th>
      <th>Tools Used</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Process new customer intake form</td>
      <td>3x/week</td>
      <td>45 min</td>
      <td>Email, spreadsheet, CRM</td>
    </tr>
    <tr>
      <td>Reconcile daily sales with payment processor</td>
      <td>Daily</td>
      <td>20 min</td>
      <td>Payment dashboard, accounting software</td>
    </tr>
    <tr>
      <td>Generate weekly inventory report</td>
      <td>Weekly</td>
      <td>90 min</td>
      <td>Inventory system, spreadsheet, email</td>
    </tr>
    <tr>
      <td>Follow up on unpaid invoices</td>
      <td>Bi-weekly</td>
      <td>30 min</td>
      <td>Accounting software, email</td>
    </tr>
    <tr>
      <td>Update product listings across sales channels</td>
      <td>Weekly</td>
      <td>2 hours</td>
      <td>E-commerce platform, spreadsheet</td>
    </tr>
  </tbody>
</table>

<p>Ask them to be honest and specific. “Manage customers” is not a task. “Copy customer info from email intake form into CRM and send welcome email” is a task. The more granular, the more useful the audit.</p>

<p>Give them one week. Don’t rush it. You want the real list, not the list people think you want to see.</p>

<p><strong>Tip:</strong> Ask people to log tasks as they do them rather than trying to remember everything at the end of the week. Memory is unreliable — people forget the small, frequent tasks that are often the best automation candidates.</p>

<h2 id="step-2-score-each-task-on-three-dimensions">Step 2: Score Each Task on Three Dimensions</h2>

<p>Once you have the inventory, score every task on three factors. Use a simple 1–5 scale for each.</p>

<h3 id="frequency-15">Frequency (1–5)</h3>

<p>How often does this task happen?</p>

<ul>
  <li>1 = Once a month or less</li>
  <li>2 = A few times a month</li>
  <li>3 = Weekly</li>
  <li>4 = Daily</li>
  <li>5 = Multiple times per day</li>
</ul>

<p>Frequency matters because automation pays off through repetition. A task that takes 10 minutes but happens 20 times a day costs 33 hours per week. Automating it saves more than an entire full-time role. A task that takes 2 hours but happens once a month saves 24 hours per year — nice, but not a priority.</p>

<h3 id="time-per-occurrence-15">Time Per Occurrence (1–5)</h3>

<p>How long does each instance take?</p>

<ul>
  <li>1 = Under 5 minutes</li>
  <li>2 = 5–15 minutes</li>
  <li>3 = 15–30 minutes</li>
  <li>4 = 30–60 minutes</li>
  <li>5 = Over 1 hour</li>
</ul>

<p>This is straightforward: longer tasks mean more time saved per automation.</p>

<h3 id="automatability-15">Automatability (1–5)</h3>

<p>This is the one that requires judgment. How feasible is it to automate this task with current tools? Score it based on these signals:</p>

<ul>
  <li><strong>5 — Highly automatable:</strong> The task follows the same steps every time, moves data between software systems, requires no human judgment, and has clear inputs and outputs. Example: copying data from an email attachment into a spreadsheet and emailing it to three people.</li>
  <li><strong>4 — Mostly automatable:</strong> The task is mostly rule-based but has a small judgment component that could be handled by an AI model. Example: sorting incoming emails into categories and drafting a response for human review.</li>
  <li><strong>3 — Partially automatable:</strong> Parts of the task can be automated, but a human needs to stay involved for key decisions. Example: reviewing expense reports — AI can flag anomalies and pre-fill categories, but a human approves.</li>
  <li><strong>2 — Mostly manual:</strong> The task requires significant human judgment, creativity, or relationship-building. Automation could help with peripheral tasks but not the core. Example: negotiating a vendor contract.</li>
  <li><strong>1 — Not automatable:</strong> The task is inherently human. Example: delivering bad news to an employee, building trust with a new client.</li>
</ul>

<p>Be honest about this score. The temptation is to mark everything as a 4 or 5 because you want to automate it. But tasks that involve nuanced judgment, emotional intelligence, or complex physical interaction are poor automation candidates — and trying to force them will waste your time and frustrate your team.</p>

<h3 id="calculate-the-priority-score">Calculate the Priority Score</h3>

<p>Multiply the three scores together:</p>

<p><strong>Priority Score = Frequency × Time × Automatability</strong></p>

<p>The maximum possible score is 125 (5 × 5 × 5). The minimum is 1. Sort your task list by priority score, highest to lowest.</p>

<p>Here’s what your scored list might look like:</p>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Freq</th>
      <th>Time</th>
      <th>Auto</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Copy email form data into CRM + send welcome</td>
      <td>5</td>
      <td>3</td>
      <td>5</td>
      <td>75</td>
    </tr>
    <tr>
      <td>Reconcile daily sales with payment processor</td>
      <td>4</td>
      <td>3</td>
      <td>4</td>
      <td>48</td>
    </tr>
    <tr>
      <td>Generate weekly inventory report</td>
      <td>3</td>
      <td>4</td>
      <td>5</td>
      <td>60</td>
    </tr>
    <tr>
      <td>Follow up on unpaid invoices</td>
      <td>2</td>
      <td>2</td>
      <td>4</td>
      <td>16</td>
    </tr>
    <tr>
      <td>Update product listings across channels</td>
      <td>3</td>
      <td>5</td>
      <td>3</td>
      <td>45</td>
    </tr>
    <tr>
      <td>Negotiate vendor contracts</td>
      <td>1</td>
      <td>5</td>
      <td>1</td>
      <td>5</td>
    </tr>
    <tr>
      <td>Deliver quarterly board presentation</td>
      <td>1</td>
      <td>5</td>
      <td>1</td>
      <td>5</td>
    </tr>
  </tbody>
</table>

<p>The scores tell a clear story. Customer intake processing and inventory reports are your top priorities. Vendor negotiation and board presentations are at the bottom — and they should be. No amount of AI automation replaces the human judgment in negotiating a contract or presenting to your board.</p>

<h2 id="step-3-map-the-top-5-processes-in-detail">Step 3: Map the Top 5 Processes in Detail</h2>

<p>Take the five highest-scoring tasks and map them step by step. For each one, document:</p>

<ol>
  <li><strong>Trigger</strong> — What starts this process? An email arriving? A time of day? A form submission?</li>
  <li><strong>Inputs</strong> — What data or materials does the process need? Where do they come from?</li>
  <li><strong>Steps</strong> — Every individual action, in order. “Open email. Download attachment. Open spreadsheet. Copy column A into column B. Save. Email to Sarah.”</li>
  <li><strong>Decision points</strong> — Where does the person make a judgment call? What are they deciding?</li>
  <li><strong>Outputs</strong> — What does the process produce? Where does it go?</li>
  <li><strong>Exceptions</strong> — What happens when something doesn’t fit the normal flow? How often do exceptions occur?</li>
  <li><strong>Time breakdown</strong> — How long does each step take? (This often reveals that one step dominates the total time.)</li>
</ol>

<p>Do this by sitting with the person who actually does the work. Watch them do it. Ask questions. Take notes. Don’t rely on documentation — if written procedures exist, they’re almost certainly outdated or incomplete compared to what people actually do.</p>

<p>The goal here is to understand the process well enough to answer one question: <strong>Could a software workflow handle steps 1–7 without human intervention, or with minimal human review?</strong></p>

<p>You’ll often discover that what seemed like a single task is actually three tasks bundled together — some automatable, some not. Unbundling them lets you automate the automatable parts and leave the rest to humans.</p>

<h2 id="step-4-identify-the-exceptions-and-decide-whether-they-matter">Step 4: Identify the Exceptions (and Decide Whether They Matter)</h2>

<p>Exceptions are where automation projects die. Here’s why: you build a workflow that handles 90% of cases perfectly. Then an unusual case comes in, the workflow fails, and someone has to clean up the mess. If exceptions happen often enough, the cleanup time exceeds the time the automation saved — and you’re worse off than before.</p>

<p>During your process mapping, pay close attention to how often exceptions occur and how they’re handled. Ask:</p>

<ul>
  <li>What percentage of cases are “normal” vs. exceptional?</li>
  <li>How much extra time does an exception take compared to a normal case?</li>
  <li>Could the exceptions be caught automatically and routed to a human, while the normal cases flow through automation?</li>
</ul>

<p>A practical rule: if exceptions are less than 10% of cases, you can automate the normal flow and route exceptions to a human. If exceptions are 30% or more, the process isn’t ready for automation — fix the process first (standardize the inputs, create templates, remove the conditions that cause exceptions) and then revisit automation.</p>

<p>This is important enough to repeat: <strong>automating a broken process just makes it break faster.</strong> If your intake form has 15 different formats because there’s no standardization, automation won’t fix that. Standardize first, automate second.</p>

<h2 id="step-5-estimate-the-time-savings">Step 5: Estimate the Time Savings</h2>

<p>For each of your top 5 processes, calculate the weekly time your team spends on it:</p>

<p><strong>Weekly hours = (frequency per week) × (time per occurrence in hours)</strong></p>

<p>Then estimate what percentage of that time automation could realistically save. Be conservative:</p>

<ul>
  <li><strong>90–100% saved</strong> — Fully automatable process with rare exceptions. The automation handles the entire task; humans only review edge cases.</li>
  <li><strong>60–80% saved</strong> — Mostly automatable. The workflow does the heavy lifting; a human spends a few minutes reviewing or approving.</li>
  <li><strong>30–50% saved</strong> — Partially automatable. The workflow handles data movement and initial processing; a human does the judgment-heavy parts.</li>
</ul>

<table>
  <thead>
    <tr>
      <th>Process</th>
      <th>Weekly Hours</th>
      <th>% Automatable</th>
      <th>Hours Saved/Week</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Customer intake processing</td>
      <td>7.5</td>
      <td>90%</td>
      <td>6.75</td>
    </tr>
    <tr>
      <td>Weekly inventory report</td>
      <td>1.5</td>
      <td>95%</td>
      <td>1.43</td>
    </tr>
    <tr>
      <td>Product listing updates</td>
      <td>6</td>
      <td>60%</td>
      <td>3.6</td>
    </tr>
    <tr>
      <td>Sales reconciliation</td>
      <td>2</td>
      <td>70%</td>
      <td>1.4</td>
    </tr>
    <tr>
      <td>Invoice follow-up</td>
      <td>1</td>
      <td>50%</td>
      <td>0.5</td>
    </tr>
  </tbody>
</table>

<p>Total estimated savings in this example: about 13.7 hours per week. Over a year, that’s 712 hours — roughly a third of a full-time role. That’s real, measurable capacity returned to your team without hiring anyone.</p>

<p>These are estimates, not promises. But they’re grounded in actual observation of actual work, which is more than most automation ROI calculations can say.</p>

<h2 id="step-6-prioritize-your-first-automation-project">Step 6: Prioritize Your First Automation Project</h2>

<p>You now have everything you need to pick your first project. The criteria are:</p>

<ol>
  <li><strong>High priority score</strong> — frequent, time-consuming, and automatable</li>
  <li><strong>Low exception rate</strong> — the process is predictable enough that automation won’t constantly break</li>
  <li><strong>Clear inputs and outputs</strong> — data comes from a defined source and goes to a defined destination</li>
  <li><strong>Low complexity</strong> — the workflow involves 2–4 systems, not 10</li>
  <li><strong>Visible impact</strong> — the time savings are noticeable to the team, building momentum for future projects</li>
</ol>

<p>Your first project should be a slam dunk, not a stretch goal. The goal of the first automation is to prove the concept, build confidence, and create a template for future work. Pick the process that scores highest on all five criteria — not the one that’s most technically ambitious.</p>

<p>In the example above, <strong>customer intake processing</strong> is the clear first choice: it happens multiple times daily, takes 45 minutes per occurrence, is highly automatable (copy data from email, enter into CRM, send templated email), has clear inputs and outputs, and would save nearly 7 hours per week.</p>

<p>The inventory report is a strong second choice — fully automatable, predictable, and easy to build.</p>

<h2 id="common-audit-mistakes-to-avoid">Common Audit Mistakes to Avoid</h2>

<p><strong>Don’t skip the audit because you “already know” what to automate.</strong> You probably know some of what to automate. You’re almost certainly missing tasks that happen frequently but invisibly — the small things people do without thinking about them, like checking a dashboard every morning or copying data between systems “just to be sure.” The audit surfaces these.</p>

<p><strong>Don’t let the audit become an excuse to delay.</strong> The audit should take one to two weeks, not three months. Set a deadline. If you’re still gathering data after two weeks, you’re overthinking it. Use what you have — a 90% complete audit is infinitely more useful than a 100% complete audit you never finish.</p>

<p><strong>Don’t score automatability based on what you’ve seen in marketing demos.</strong> A demo shows a tool doing something impressive under controlled conditions. Your real processes have messy data, unusual edge cases, and human quirks. Score based on what your team actually does, not what a sales deck suggests is possible.</p>

<p><strong>Don’t forget to ask the people doing the work.</strong> Managers often have a different view of a process than the person who executes it daily. The person doing the work knows where the friction is, which steps take the longest, and which parts they’d most like to hand off. Their input is the most valuable data in the audit.</p>

<p><strong>Don’t automate processes that should be eliminated.</strong> Sometimes the best automation is no automation. If a report nobody reads takes 90 minutes a week to produce, the answer isn’t to automate the report — it’s to stop making the report. Before automating anything, ask: does this process need to exist at all?</p>

<h2 id="what-to-do-after-the-audit">What to Do After the Audit</h2>

<p>Once you’ve completed the audit and selected your first project, the path forward is clear:</p>

<ol>
  <li>
    <p><strong>Choose your tools.</strong> For most small businesses, a self-hosted automation platform like n8n combined with a local LLM via Ollama covers the majority of use cases. Both are open source, run on your own infrastructure, and keep your data private.</p>
  </li>
  <li>
    <p><strong>Build the first workflow.</strong> Start simple. Automate the happy path first — the 90% of cases that follow the normal flow. Handle exceptions later by routing them to a human for review.</p>
  </li>
  <li>
    <p><strong>Measure the actual savings.</strong> After the workflow has been running for two weeks, compare the time spent before and after. Your audit estimates gave you a target — now see how close reality came.</p>
  </li>
  <li>
    <p><strong>Move to the next project on your list.</strong> Use the same framework. Each automation builds on the last, and your team gets more comfortable with the process each time.</p>
  </li>
</ol>

<p>The audit gives you a roadmap. The roadmap gives you confidence that you’re automating the right things in the right order. And confidence is what separates an automation initiative that transforms your operations from one that collects dust.</p>

<h2 id="the-bottom-line">The Bottom Line</h2>

<p>An audit isn’t a glamorous step. There’s no new software to install, no demo to watch, no “aha” moment where AI does something surprising. It’s a spreadsheet, a series of conversations, and a few hours of observation.</p>

<p>But it’s the single highest-leverage thing you can do before starting any automation project. It ensures you build the right thing, in the right order, for the right reasons — and that the result actually saves time instead of creating new work.</p>

<p>If you want help running a process audit for your business, we can guide you through it — from the initial inventory to selecting your first automation project and building it with open-source tools. No sales pitch, no proprietary lock-in, just practical guidance based on what your team actually does every day.</p>

<p><strong><a href="/#contact">Talk to us about your automation audit</a></strong></p>

<hr />

<p><em>ARDOT Consulting helps small and mid-size businesses identify, prioritize, and implement AI automation using open-source tools. We believe in practical automation — the kind that saves real hours and doesn’t create new problems. <a href="/#contact">Get in touch</a> to start the conversation.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="automation" /><category term="process-audit" /><category term="workflow-analysis" /><category term="strategy" /><category term="business-processes" /><summary type="html"><![CDATA[Before you automate anything, you need to know where to start. Here's a practical, step-by-step process audit framework that helps you find the automation opportunities that actually matter — without buying tools you don't need yet.]]></summary></entry><entry><title type="html">Self-Hosting Mattermost: Replace Slack with Your Own Team Chat Platform</title><link href="https://www.ardotconsulting.com/blog/2026/10/08/self-hosting-mattermost-replace-slack-with-your-own-team-chat-platform/" rel="alternate" type="text/html" title="Self-Hosting Mattermost: Replace Slack with Your Own Team Chat Platform" /><published>2026-10-08T00:00:00-07:00</published><updated>2026-10-08T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/10/08/self-hosting-mattermost-replace-slack-with-your-own-team-chat-platform</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/10/08/self-hosting-mattermost-replace-slack-with-your-own-team-chat-platform/"><![CDATA[<p>Every time someone joins your team, Slack sends you a bill. At $8–$15 per user per month, a 25-person company pays $2,400–$4,500 a year just for chat. Add in the fact that your conversations — including sensitive business discussions, file shares, and integration webhooks — live on someone else’s servers, and you start to wonder whether there’s a better way.</p>

<p>There is. <a href="https://mattermost.com/">Mattermost</a> is an open source team messaging platform that does most of what Slack does, runs on your own infrastructure, and costs nothing in licensing for teams using the free Team Edition. You own the data, you control the access, and you never get a per-seat invoice.</p>

<p>In this guide, we’ll walk through what Mattermost actually offers, how it compares to Slack, and how to get it running with Docker in under an hour.</p>

<h2 id="what-mattermost-actually-does">What Mattermost Actually Does</h2>

<p>Mattermost is a real-time messaging platform designed for teams. If you’ve used Slack, the interface will feel immediately familiar — channels on the left, messages in the center, threads, direct messages, file attachments, emoji reactions. But under the hood, it’s a fundamentally different model.</p>

<p><strong>Core features:</strong></p>

<ul>
  <li><strong>Channels</strong> — Public, private, and direct message channels organized by team</li>
  <li><strong>Threads</strong> — Reply in threads to keep conversations organized</li>
  <li><strong>File sharing</strong> — Drag-and-drop file uploads with preview support</li>
  <li><strong>Search</strong> — Full-text search across all messages and files you have access to</li>
  <li><strong>Integrations</strong> — Incoming webhooks, outgoing webhooks, slash commands, and bot accounts</li>
  <li><strong>Mobile apps</strong> — Native iOS and Android apps that connect to your self-hosted server</li>
  <li><strong>Desktop apps</strong> — Native apps for Windows, Mac, and Linux</li>
  <li><strong>Message history</strong> — No limit on message history (Slack’s free plan caps you at 10,000 messages)</li>
</ul>

<p>The key difference from Slack isn’t the feature list — it’s the architecture. Mattermost runs on a server you control. Your messages, files, and metadata never touch a third-party SaaS provider.</p>

<h2 id="mattermost-vs-slack-the-honest-comparison">Mattermost vs Slack: The Honest Comparison</h2>

<p>Let’s not pretend Mattermost is a drop-in replacement for every Slack feature. It’s not. But for most small-to-mid-size businesses, the trade-offs favor self-hosting. Here’s an honest comparison:</p>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Slack (Pro)</th>
      <th>Mattermost (Team Edition)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Monthly cost per user</td>
      <td>$8–$15</td>
      <td>$0</td>
    </tr>
    <tr>
      <td>Message history limit</td>
      <td>90 days</td>
      <td>Unlimited</td>
    </tr>
    <tr>
      <td>File storage limit</td>
      <td>10–20 GB per user</td>
      <td>Whatever your server has</td>
    </tr>
    <tr>
      <td>Data location</td>
      <td>Slack’s cloud</td>
      <td>Your server</td>
    </tr>
    <tr>
      <td>Data retention control</td>
      <td>Limited</td>
      <td>Full control</td>
    </tr>
    <tr>
      <td>SSO/SAML</td>
      <td>Enterprise plan only</td>
      <td>Built in (LDAP/AD)</td>
    </tr>
    <tr>
      <td>Custom integrations</td>
      <td>App Marketplace + API</td>
      <td>Webhooks, API, plugins</td>
    </tr>
    <tr>
      <td>Guest accounts</td>
      <td>Paid add-on</td>
      <td>Free, unlimited</td>
    </tr>
    <tr>
      <td>Self-hosting option</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Compliance (HIPAA, FedRAMP)</td>
      <td>Enterprise only</td>
      <td>Possible with self-hosting</td>
    </tr>
    <tr>
      <td>Setup effort</td>
      <td>None (SaaS)</td>
      <td>1–2 hours initial setup</td>
    </tr>
    <tr>
      <td>Maintenance effort</td>
      <td>None</td>
      <td>Patching, backups, monitoring</td>
    </tr>
  </tbody>
</table>

<p>The trade-off is clear: Slack is zero-effort but you pay per seat and hand over your data. Mattermost requires some upfront setup and ongoing maintenance, but you keep your data and eliminate per-seat costs.</p>

<p><strong>When Slack makes more sense:</strong> You have fewer than 10 people, no compliance requirements, and you’d rather not manage infrastructure. The convenience is worth the cost.</p>

<p><strong>When Mattermost makes more sense:</strong> You have 15+ people, you care about data sovereignty, you have compliance requirements, or you already run your own servers for other things.</p>

<h2 id="what-youll-need">What You’ll Need</h2>

<p>Before we start, here’s the infrastructure you’ll need:</p>

<ul>
  <li><strong>A server</strong> — A VPS with 2 GB RAM and 20 GB disk (a $5–$10/month VPS from Hetzner, OVH, or DigitalOcean works fine). If you already have a server running other services, Mattermost can share it.</li>
  <li><strong>Docker and Docker Compose</strong> — We’ll use Docker to keep the installation clean and portable.</li>
  <li><strong>A domain name</strong> — Something like <code class="language-plaintext highlighter-rouge">chat.yourcompany.com</code> with an SSL certificate (we’ll use Let’s Encrypt, which is free).</li>
  <li><strong>Basic comfort with the command line</strong> — You don’t need to be a sysadmin, but you should be comfortable running commands and editing configuration files.</li>
</ul>

<h2 id="step-1-set-up-the-directory-structure">Step 1: Set Up the Directory Structure</h2>

<p>On your server, create a directory for the Mattermost deployment:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> /opt/mattermost/<span class="o">{</span>config,data,logs,plugins<span class="o">}</span>
<span class="nb">cd</span> /opt/mattermost
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">data</code> directory is where uploaded files and the database will live, so make sure the disk it’s on has enough space. For a 25-person team, 20–50 GB is plenty to start.</p>

<h2 id="step-2-create-the-docker-compose-file">Step 2: Create the Docker Compose File</h2>

<p>Create a <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> file in <code class="language-plaintext highlighter-rouge">/opt/mattermost/</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:16-alpine</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">mattermost-postgres</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">POSTGRES_USER</span><span class="pi">:</span> <span class="s">mattermost</span>
      <span class="na">POSTGRES_PASSWORD</span><span class="pi">:</span> <span class="s">CHANGE_THIS_TO_A_STRONG_PASSWORD</span>
      <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">mattermost</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./data/postgres:/var/lib/postgresql/data</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">mattermost-net</span>

  <span class="na">mattermost</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">mattermost/mattermost-team-edition:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">mattermost</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">postgres</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">127.0.0.1:8065:8065"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">MM_SQLSETTINGS_DRIVERNAME</span><span class="pi">:</span> <span class="s">postgres</span>
      <span class="na">MM_SQLSETTINGS_DATASOURCE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">postgres://mattermost:CHANGE_THIS_TO_A_STRONG_PASSWORD@postgres:5432/mattermost?sslmode=disable&amp;connect_timeout=10"</span>
      <span class="na">MM_SERVICESETTINGS_SITEURL</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://chat.yourcompany.com"</span>
      <span class="na">MM_SERVICESETTINGS_ENABLELOCALMODE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">MM_FILESETTINGS_DIRECTORY</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/mattermost/data"</span>
      <span class="na">MM_LOGSETTINGS_CONSOLELEVEL</span><span class="pi">:</span> <span class="s">INFO</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./config:/mattermost/config</span>
      <span class="pi">-</span> <span class="s">./data:/mattermost/data</span>
      <span class="pi">-</span> <span class="s">./logs:/mattermost/logs</span>
      <span class="pi">-</span> <span class="s">./plugins:/mattermost/plugins</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">mattermost-net</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">mattermost-net</span><span class="pi">:</span>
    <span class="na">driver</span><span class="pi">:</span> <span class="s">bridge</span>
</code></pre></div></div>

<p>A few things to note:</p>

<ul>
  <li><strong>Change the password</strong> — Replace <code class="language-plaintext highlighter-rouge">CHANGE_THIS_TO_A_STRONG_PASSWORD</code> with a real, strong password. Use the same one in both places.</li>
  <li><strong>The database password</strong> — Use the same value in the <code class="language-plaintext highlighter-rouge">POSTGRES_PASSWORD</code> and the <code class="language-plaintext highlighter-rouge">DATASOURCE</code> connection string.</li>
  <li><strong>The SiteURL</strong> — Change <code class="language-plaintext highlighter-rouge">chat.yourcompany.com</code> to your actual domain.</li>
  <li><strong>Port binding</strong> — We bind to <code class="language-plaintext highlighter-rouge">127.0.0.1:8065</code> so Mattermost is only accessible locally. We’ll put a reverse proxy in front for SSL.</li>
</ul>

<h2 id="step-3-start-the-services">Step 3: Start the Services</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /opt/mattermost
docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>This downloads the images and starts both PostgreSQL and Mattermost. The first startup takes 1–2 minutes. Check that both containers are running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose ps
</code></pre></div></div>

<p>You should see both services with a status of “running.” If Mattermost keeps restarting, check the logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose logs mattermost
</code></pre></div></div>

<p>The most common issue is a mismatch between the database password in the <code class="language-plaintext highlighter-rouge">POSTGRES_PASSWORD</code> environment variable and the <code class="language-plaintext highlighter-rouge">DATASOURCE</code> connection string. Double-check they’re identical.</p>

<h2 id="step-4-set-up-the-reverse-proxy-with-ssl">Step 4: Set Up the Reverse Proxy with SSL</h2>

<p>Mattermost listens on port 8065 locally, but you need HTTPS for browser security and for the Mattermost desktop and mobile apps to connect properly. We’ll use Caddy as a reverse proxy because it automatically handles Let’s Encrypt certificates — no manual cert management.</p>

<p>Create a <code class="language-plaintext highlighter-rouge">Caddyfile</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chat.yourcompany.com {
    reverse_proxy localhost:8065
}
</code></pre></div></div>

<p>Install and start Caddy (on Ubuntu/Debian):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> debian-keyring debian-archive-keyring apt-transport-https curl
curl <span class="nt">-1sLf</span> <span class="s1">'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'</span> | <span class="nb">sudo </span>gpg <span class="nt">--dearmor</span> <span class="nt">-o</span> /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl <span class="nt">-1sLf</span> <span class="s1">'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'</span> | <span class="nb">sudo tee</span> /etc/apt/sources.list.d/caddy-stable.list
<span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install </span>caddy
</code></pre></div></div>

<p>Caddy will start automatically, read the Caddyfile, obtain a Let’s Encrypt certificate, and proxy traffic to Mattermost. Within a minute, <code class="language-plaintext highlighter-rouge">https://chat.yourcompany.com</code> should show the Mattermost setup screen.</p>

<p>If you prefer nginx, you can use <code class="language-plaintext highlighter-rouge">nginx</code> with <code class="language-plaintext highlighter-rouge">certbot</code> — the setup is a bit more involved but equally valid. Caddy is simpler because the SSL certificate management is fully automatic.</p>

<h2 id="step-5-create-your-admin-account">Step 5: Create Your Admin Account</h2>

<p>Visit <code class="language-plaintext highlighter-rouge">https://chat.yourcompany.com</code> in your browser. You’ll see the initial setup screen:</p>

<ol>
  <li><strong>Create the first user</strong> — Enter your email, username, and password. This becomes the admin account.</li>
  <li><strong>Create your first team</strong> — Give it a name (e.g., “YourCompany” or “Engineering”). This becomes the primary workspace.</li>
  <li><strong>Create your first channels</strong> — Mattermost creates default channels like “Town Square” (the general channel) and “Off-Topic.” Add channels for your teams or projects.</li>
</ol>

<p>That’s it. Your Mattermost instance is live and accessible to your team. Share the URL with your team members and they can create accounts from the sign-up page.</p>

<h2 id="step-6-configure-key-settings">Step 6: Configure Key Settings</h2>

<p>Once you’re logged in as admin, go to <strong>System Console</strong> (the gear icon → System Console) and review these important settings:</p>

<h3 id="email-notifications">Email Notifications</h3>

<p>Without email notifications, your team won’t know when they have messages. Go to <strong>Environment → SMTP</strong> and configure your SMTP server. If you don’t have an SMTP server, you can use a service like <a href="https://postmark.com/">Postmark</a> or run your own with <a href="https://docs.postalserver.io/">Postal</a>.</p>

<p>Test the connection from the System Console before moving on.</p>

<h3 id="file-upload-size">File Upload Size</h3>

<p>By default, Mattermost allows file uploads up to 100 MB. Adjust this in <strong>Site Configuration → File Storage</strong> if you need more or less. Keep in mind that files are stored on your server’s disk, so set a reasonable limit based on available space.</p>

<h3 id="data-retention">Data Retention</h3>

<p>If your industry requires data retention policies (or the opposite — auto-deletion of old messages), configure this in <strong>Data Retention Policy</strong>. You can set rules to keep messages indefinitely, delete after N days, or delete based on channel type.</p>

<h3 id="session-length">Session Length</h3>

<p>In <strong>Security → Sessions</strong>, you can control how long users stay logged in. For sensitive environments, set shorter session lengths and require re-authentication.</p>

<h2 id="step-7-invite-your-team-and-create-channels">Step 7: Invite Your Team and Create Channels</h2>

<p>With the server configured, set up your team structure:</p>

<p><strong>Channel suggestions for a small business:</strong></p>

<table>
  <thead>
    <tr>
      <th>Channel</th>
      <th>Purpose</th>
      <th>Visibility</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#town-square</code></td>
      <td>Company-wide announcements</td>
      <td>Public</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#general</code></td>
      <td>Day-to-day discussion</td>
      <td>Public</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#engineering</code></td>
      <td>Dev team discussions</td>
      <td>Public</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#sales</code></td>
      <td>Sales pipeline, lead updates</td>
      <td>Private</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#hr</code></td>
      <td>HR discussions, hiring</td>
      <td>Private</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#incidents</code></td>
      <td>Production incidents, on-call</td>
      <td>Public</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">#random</code></td>
      <td>Non-work chatter</td>
      <td>Public</td>
    </tr>
  </tbody>
</table>

<p>To invite people, go to the team menu → <strong>Get Team Invite Link</strong>. Share the link with your team. They’ll create their own accounts and join automatically.</p>

<p>For larger organizations, you can connect Mattermost to LDAP or Active Directory for centralized authentication. This is available in the free Team Edition — no enterprise license needed.</p>

<h2 id="step-8-set-up-integrations">Step 8: Set Up Integrations</h2>

<p>Mattermost supports the same integration patterns as Slack: incoming webhooks, outgoing webhooks, slash commands, and bot accounts. Here’s a practical example.</p>

<h3 id="example-post-a-notification-from-n8n">Example: Post a Notification from n8n</h3>

<p>If you use n8n for automation (see our <a href="/blog/2026/08/17/how-to-build-your-first-ai-automation-workflow-with-n8n/">n8n guide</a>), you can send notifications to a Mattermost channel when a workflow completes.</p>

<ol>
  <li>In Mattermost, go to the channel where you want notifications → <strong>Channel Settings → Integrations → Incoming Webhook</strong>.</li>
  <li>Create a webhook and copy the URL.</li>
  <li>In n8n, add an HTTP Request node:</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"POST"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://chat.yourcompany.com/hooks/XXXXXXXXXX"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"headers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Content-Type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"✅ Workflow completed:  at "</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Now every time your n8n workflow runs, you get a notification in Mattermost. This works for any system that can make an HTTP POST — Odoo, Metabase, your CI/CD pipeline, custom scripts, anything.</p>

<h3 id="slash-commands">Slash Commands</h3>

<p>You can create custom slash commands that trigger external actions. For example, <code class="language-plaintext highlighter-rouge">/lookup ACME Corp</code> could query your CRM and return the company’s details in the channel. This is configured in <strong>Integrations → Slash Commands</strong> with a callback URL that receives the command and returns a Mattermost-formatted response.</p>

<h2 id="step-9-back-up-your-data">Step 9: Back Up Your Data</h2>

<p>This is the most important step. Since you’re self-hosting, you’re responsible for backups. Here’s a simple backup strategy:</p>

<p><strong>What to back up:</strong></p>
<ul>
  <li>The PostgreSQL database (contains all messages, users, and settings)</li>
  <li>The <code class="language-plaintext highlighter-rouge">data</code> directory (contains uploaded files)</li>
</ul>

<p><strong>Simple backup script:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nv">BACKUP_DIR</span><span class="o">=</span><span class="s2">"/backups/mattermost"</span>
<span class="nv">DATE</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +%Y%m%d_%H%M%S<span class="si">)</span>

<span class="c"># Create backup directory</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$BACKUP_DIR</span>

<span class="c"># Back up the database</span>
docker <span class="nb">exec </span>mattermost-postgres pg_dump <span class="nt">-U</span> mattermost mattermost | <span class="nb">gzip</span> <span class="o">&gt;</span> <span class="nv">$BACKUP_DIR</span>/db_<span class="nv">$DATE</span>.sql.gz

<span class="c"># Back up uploaded files</span>
<span class="nb">tar </span>czf <span class="nv">$BACKUP_DIR</span>/files_<span class="nv">$DATE</span>.tar.gz <span class="nt">-C</span> /opt/mattermost data/

<span class="c"># Keep only the last 7 days of backups</span>
find <span class="nv">$BACKUP_DIR</span> <span class="nt">-mtime</span> +7 <span class="nt">-delete</span>
</code></pre></div></div>

<p>Schedule this with a cron job:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run backup daily at 2 AM</span>
0 2 <span class="k">*</span> <span class="k">*</span> <span class="k">*</span> /opt/mattermost/backup.sh
</code></pre></div></div>

<p>For offsite backups, sync the backup directory to an S3-compatible storage service like MinIO (which you can also self-host) or a remote server using <code class="language-plaintext highlighter-rouge">rsync</code>.</p>

<h2 id="step-10-keep-it-updated">Step 10: Keep It Updated</h2>

<p>Mattermost releases updates regularly. To update:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /opt/mattermost
docker compose pull
docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>This pulls the latest image and restarts the containers. The database schema is migrated automatically. The whole process takes less than a minute.</p>

<p><strong>Update best practices:</strong></p>
<ul>
  <li>Check the <a href="https://docs.mattermost.com/administration/upgrade.html">release notes</a> before upgrading to a major version</li>
  <li>Back up your database before upgrading (run the backup script above)</li>
  <li>Test on a staging instance if you have critical workflows</li>
</ul>

<h2 id="the-real-cost-comparison">The Real Cost Comparison</h2>

<p>Let’s put real numbers on this. Assume a 25-person team:</p>

<table>
  <thead>
    <tr>
      <th>Cost Item</th>
      <th>Slack (Pro)</th>
      <th>Mattermost (Self-Hosted)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Per-seat licensing</td>
      <td>$2,400/year ($8/user × 25 × 12)</td>
      <td>$0</td>
    </tr>
    <tr>
      <td>Server (VPS)</td>
      <td>—</td>
      <td>$60–120/year</td>
    </tr>
    <tr>
      <td>Domain + SSL</td>
      <td>—</td>
      <td>$10/year (SSL is free via Let’s Encrypt)</td>
    </tr>
    <tr>
      <td>Backup storage</td>
      <td>—</td>
      <td>$0–50/year</td>
    </tr>
    <tr>
      <td><strong>Year 1 total</strong></td>
      <td><strong>$2,400</strong></td>
      <td><strong>$70–180</strong></td>
    </tr>
    <tr>
      <td><strong>Year 2 total</strong></td>
      <td><strong>$4,800</strong></td>
      <td><strong>$140–360</strong></td>
    </tr>
    <tr>
      <td><strong>3-year total</strong></td>
      <td><strong>$7,200</strong></td>
      <td><strong>$210–540</strong></td>
    </tr>
  </tbody>
</table>

<p>For a 25-person team, Mattermost saves roughly <strong>$2,200–$2,300 per year</strong> even after accounting for server costs. For a 50-person team, the savings double. The larger your team, the more dramatic the savings.</p>

<p>The hidden cost is your time. Budget 2–4 hours for initial setup and 1–2 hours per quarter for updates and maintenance. If you value your time at $100/hour, that’s $200–$400/year in labor — still a fraction of the Slack licensing cost.</p>

<h2 id="what-about-the-desktop-and-mobile-apps">What About the Desktop and Mobile Apps?</h2>

<p>Mattermost provides native apps for all platforms:</p>

<ul>
  <li><strong>Desktop:</strong> Windows, macOS, Linux — available from <a href="https://mattermost.com/apps/">mattermost.com/apps</a> or your package manager</li>
  <li><strong>Mobile:</strong> iOS (App Store) and Android (Play Store/F-Droid)</li>
</ul>

<p>The apps connect to your self-hosted server by entering the server URL. They support push notifications (Mattermost runs its own push notification service — the HPNS — which is free for the Team Edition).</p>

<p>If you’ve used the Slack desktop app, the Mattermost desktop app feels nearly identical: multiple teams in a sidebar, unread badges, notification preferences, dark mode.</p>

<h2 id="when-self-hosting-chat-makes-sense">When Self-Hosting Chat Makes Sense</h2>

<p>Self-hosting your team chat isn’t for everyone. But it makes particular sense if:</p>

<ol>
  <li>
    <p><strong>You’re already self-hosting other tools</strong> — If you run Odoo, n8n, or Plausible on your own server, adding Mattermost is a natural extension. You already have the skills and infrastructure.</p>
  </li>
  <li>
    <p><strong>You have compliance requirements</strong> — HIPAA, GDPR, or industry-specific data handling rules are much easier to satisfy when your data stays on servers you control.</p>
  </li>
  <li>
    <p><strong>You have a growing team</strong> — The per-seat cost of Slack scales linearly with headcount. Mattermost doesn’t. At 30+ people, the savings become significant.</p>
  </li>
  <li>
    <p><strong>You want unlimited message history</strong> — Slack’s free and low-tier plans cap your message history. Mattermost keeps everything, forever, at no extra cost.</p>
  </li>
  <li>
    <p><strong>You want deeper integrations</strong> — Self-hosted Mattermost gives you full access to the database and API. You can build custom integrations, run analytics on message patterns, or connect it to your data warehouse.</p>
  </li>
</ol>

<h2 id="common-gotchas">Common Gotchas</h2>

<p>A few things catch people off guard when setting up Mattermost for the first time:</p>

<p><strong>Email notifications don’t work out of the box.</strong> Mattermost needs an SMTP server to send emails. If you skip this, your team won’t get email notifications for mentions or direct messages. Configure SMTP in the System Console before inviting your team.</p>

<p><strong>Push notifications need the internet.</strong> Even though Mattermost is self-hosted, mobile push notifications go through Mattermost’s notification relay service (HPNS). This is free but does require outbound internet access from your server. If you’re on a fully air-gapped network, push notifications won’t work without additional configuration.</p>

<p><strong>File uploads can fill your disk.</strong> Unlike Slack, where file storage is someone else’s problem, Mattermost stores files on your server. Monitor disk usage and set upload size limits. A 25-person team uploading documents and screenshots will generate 5–20 GB of files per year.</p>

<p><strong>Upgrades occasionally require database migration.</strong> Major version upgrades may run database migrations that can take several minutes. Schedule upgrades during off-hours and always back up first.</p>

<h2 id="next-steps">Next Steps</h2>

<p>Mattermost is just one piece of a self-hosted communication stack. Once your team chat is running, consider:</p>

<ul>
  <li><strong>Connect it to your automation</strong> — Use n8n to send automated alerts to Mattermost channels when things happen (new leads, completed tasks, system alerts)</li>
  <li><strong>Add Mattermost to your backup strategy</strong> — Include the database and file storage in your regular backup routine</li>
  <li><strong>Set up monitoring</strong> — Use a tool like Uptime Kuma to alert you if your Mattermost instance goes down</li>
  <li><strong>Create a guest access policy</strong> — If you work with external contractors, configure guest accounts with limited channel access</li>
</ul>

<p>Self-hosting your team chat is one of the highest-impact, lowest-cost moves a small business can make. You get enterprise-grade messaging, unlimited history, full data control, and per-seat costs that stay at zero no matter how much your team grows.</p>

<hr />

<p><em>Want help setting up Mattermost or migrating from Slack? <a href="/#contact">Get in touch</a> — ARDOT Consulting specializes in open source infrastructure for small businesses. We’ll handle the setup, migration, and training so your team can start chatting on day one.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="mattermost" /><category term="self-hosting" /><category term="team-chat" /><category term="open-source" /><category term="slack-alternative" /><category term="docker" /><summary type="html"><![CDATA[Slack's per-seat pricing adds up fast. Here's how to self-host Mattermost — a fully featured open source team chat platform — with Docker, keep your data under your own roof, and cut your messaging bill to near zero.]]></summary></entry><entry><title type="html">Self-Hosting Nextcloud: Replace Google Workspace with Your Own File Sync, Calendar, and Collaboration Platform</title><link href="https://www.ardotconsulting.com/blog/2026/10/06/self-hosting-nextcloud-replace-google-workspace-with-your-own-file-sync-and-collaboration-platform/" rel="alternate" type="text/html" title="Self-Hosting Nextcloud: Replace Google Workspace with Your Own File Sync, Calendar, and Collaboration Platform" /><published>2026-10-06T00:00:00-07:00</published><updated>2026-10-06T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/10/06/self-hosting-nextcloud-replace-google-workspace-with-your-own-file-sync-and-collaboration-platform</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/10/06/self-hosting-nextcloud-replace-google-workspace-with-your-own-file-sync-and-collaboration-platform/"><![CDATA[<p>Your business runs on documents, calendars, and shared files. Right now, most of that lives in Google Workspace. You pay $7 to $14 per user per month for the privilege of storing your company’s most sensitive information on servers you don’t control, governed by terms of service you’ve never read, in a data center you can’t visit. When Google decides to change a feature, deprecate a service, or adjust pricing — and they do, regularly — you have no say in the matter.</p>

<p>For a 15-person team on the Business Standard plan at $12/user/month, that’s $2,160 per year. Not catastrophic, but not trivial either. The real cost isn’t the subscription fee, though. It’s the dependency. Your files, your calendar history, your contacts, your shared documents — all of it lives in a system you can’t self-audit, can’t back up to your own metal without proprietary tooling, and can’t run offline if your internet drops or Google has an outage.</p>

<p>There’s a better way. <strong>Nextcloud</strong> is a free, open-source platform that does most of what Google Workspace does — file sync, calendar, contacts, document editing, video chat, task management — and runs entirely on your own server. No per-user fees. No data leaving your network. No vendor making decisions about your workflow.</p>

<p>This guide walks through setting up Nextcloud with Docker, migrating your data off Google, and configuring the features your team actually uses day-to-day.</p>

<h2 id="what-nextcloud-replaces">What Nextcloud Replaces</h2>

<p>Before we get into setup, let’s be honest about what Nextcloud does well and where it falls short. No tool is perfect, and Nextcloud has trade-offs you should understand before committing.</p>

<table>
  <thead>
    <tr>
      <th>Google Workspace Feature</th>
      <th>Nextcloud Equivalent</th>
      <th>How Well It Works</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Google Drive (file storage)</td>
      <td>Nextcloud Files</td>
      <td>Excellent — full sync, sharing, versioning</td>
    </tr>
    <tr>
      <td>Google Calendar</td>
      <td>Nextcloud Calendar</td>
      <td>Excellent — CalDAV standard, works with all clients</td>
    </tr>
    <tr>
      <td>Google Contacts</td>
      <td>Nextcloud Contacts</td>
      <td>Excellent — CardDAV standard, syncs everywhere</td>
    </tr>
    <tr>
      <td>Google Docs (collaborative editing)</td>
      <td>Nextcloud Text / Collabora</td>
      <td>Good — real-time co-editing, slightly less polished</td>
    </tr>
    <tr>
      <td>Google Sheets</td>
      <td>Collabora Calc</td>
      <td>Good — covers most spreadsheet needs</td>
    </tr>
    <tr>
      <td>Google Meet</td>
      <td>Nextcloud Talk</td>
      <td>Adequate — works for internal calls, not great for large external meetings</td>
    </tr>
    <tr>
      <td>Gmail</td>
      <td>Nextcloud Mail (or self-hosted mail server)</td>
      <td>Adequate for light use; most teams keep a dedicated mail provider</td>
    </tr>
    <tr>
      <td>Google Chat</td>
      <td>Nextcloud Talk (chat)</td>
      <td>Adequate for internal team chat</td>
    </tr>
  </tbody>
</table>

<p>The file sync, calendar, and contacts replacements are genuinely excellent. Nextcloud uses industry standards (WebDAV, CalDAV, CardDAV) that work with every operating system and mobile app. You’re not locked into Nextcloud’s client — you can use any CalDAV-compatible calendar app.</p>

<p>Document collaboration is good but not as seamless as Google Docs. Nextcloud integrates with <strong>Collabora</strong> (a LibreOffice-based online editor) or <strong>OnlyOffice</strong> for real-time document editing. It works, but the interface is a bit more utilitarian. For most business documents — proposals, internal memos, meeting notes — it’s more than sufficient.</p>

<p>Email is the one area where Nextcloud isn’t a full replacement. While it has a mail app, running a full mail server (with spam filtering, DKIM, DMARC, deliverability management) is a specialized skill that most small businesses should outsource. We recommend keeping a dedicated email provider — we use and recommend <strong>Mailcow</strong> (open source, self-hosted) or a privacy-focused provider like <strong>Posteo</strong> or <strong>Migadu</strong> for email, and using Nextcloud for everything else.</p>

<h2 id="what-you-need">What You Need</h2>

<ul>
  <li><strong>A server:</strong> A VPS with at least 2GB RAM and 40GB storage (Hetzner, OVH, or any provider you trust). For a team of 15 with moderate file storage, 4GB RAM and 100GB+ storage is more comfortable. You can also run it on a dedicated office machine.</li>
  <li><strong>Docker and Docker Compose:</strong> Installed on your server.</li>
  <li><strong>A domain name:</strong> Something like <code class="language-plaintext highlighter-rouge">files.yourcompany.com</code>. You can get one from any registrar — we use and recommend <strong>Porkbun</strong> or <strong>Namecheap</strong>.</li>
  <li><strong>30 minutes:</strong> The actual setup is fast. Migration takes longer depending on how much data you have.</li>
</ul>

<h2 id="step-1-set-up-nextcloud-with-docker">Step 1: Set Up Nextcloud with Docker</h2>

<p>Create a directory for your Nextcloud deployment and make a <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">db</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:16</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">nextcloud_db</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./db_data:/var/lib/postgresql/data</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">POSTGRES_DB=nextcloud</span>
      <span class="pi">-</span> <span class="s">POSTGRES_USER=nextcloud</span>
      <span class="pi">-</span> <span class="s">POSTGRES_PASSWORD=CHANGE_THIS_TO_A_STRONG_PASSWORD</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">nextcloud_net</span>

  <span class="na">nextcloud</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">nextcloud:apache</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">nextcloud_app</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">db</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">8080:80"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./nextcloud_data:/var/www/html</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">POSTGRES_HOST=db</span>
      <span class="pi">-</span> <span class="s">POSTGRES_DB=nextcloud</span>
      <span class="pi">-</span> <span class="s">POSTGRES_USER=nextcloud</span>
      <span class="pi">-</span> <span class="s">POSTGRES_PASSWORD=CHANGE_THIS_TO_A_STRONG_PASSWORD</span>
      <span class="pi">-</span> <span class="s">NEXTCLOUD_ADMIN_USER=admin</span>
      <span class="pi">-</span> <span class="s">NEXTCLOUD_ADMIN_PASSWORD=CHANGE_THIS_TOO</span>
      <span class="pi">-</span> <span class="s">NEXTCLOUD_TRUSTED_DOMAINS=files.yourcompany.com</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">nextcloud_net</span>

  <span class="na">cron</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">nextcloud:apache</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">nextcloud_cron</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">nextcloud</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./nextcloud_data:/var/www/html</span>
    <span class="na">entrypoint</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">/bin/sh -c "echo '*/5 * * * * php /var/www/html/occ cron:run' &gt; /var/spool/cron/crontabs/www-data &amp;&amp; crond -f"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">nextcloud_net</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">nextcloud_net</span><span class="pi">:</span>
    <span class="na">driver</span><span class="pi">:</span> <span class="s">bridge</span>
</code></pre></div></div>

<p>A few things to note about this setup:</p>

<ul>
  <li><strong>PostgreSQL</strong> is used instead of the default SQLite. For any team larger than 2-3 people, you want a real database. SQLite works for testing but will slow down under concurrent use.</li>
  <li><strong>The cron container</strong> runs Nextcloud’s background jobs every 5 minutes. This handles things like file scanning, notification delivery, and calendar reminders. Without it, some features silently stop working.</li>
  <li><strong>Trusted domains</strong> are set to your actual domain. Nextcloud blocks requests from unknown domains as a security measure.</li>
</ul>

<p>Change the passwords, then start it up:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Wait about 30 seconds for the containers to initialize, then check that it’s running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose ps
</code></pre></div></div>

<p>You should see all three containers (db, app, cron) running. Nextcloud is now accessible on port 8080 of your server.</p>

<h2 id="step-2-put-it-behind-a-reverse-proxy-with-https">Step 2: Put It Behind a Reverse Proxy with HTTPS</h2>

<p>Running Nextcloud on port 8080 without encryption is fine for testing, but you need HTTPS before anyone actually uses it. We’ll use <strong>Caddy</strong> as a reverse proxy because it automatically handles Let’s Encrypt certificates — no manual certbot renewals, no certificate expiry surprises.</p>

<p>Add Caddy to your docker-compose setup:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">caddy</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">caddy:2</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">nextcloud_caddy</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">80:80"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">443:443"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./Caddyfile:/etc/caddy/Caddyfile</span>
      <span class="pi">-</span> <span class="s">caddy_data:/data</span>
      <span class="pi">-</span> <span class="s">caddy_config:/config</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">nextcloud</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">nextcloud_net</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">caddy_data</span><span class="pi">:</span>
  <span class="na">caddy_config</span><span class="pi">:</span>
</code></pre></div></div>

<p>Create a <code class="language-plaintext highlighter-rouge">Caddyfile</code> in the same directory:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>files.yourcompany.com {
    reverse_proxy nextcloud:80
    
    header {
        Strict-Transport-Security "max-age=31536000; includeSubdomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "no-referrer"
    }
}
</code></pre></div></div>

<p>Point your domain’s DNS A record to your server’s IP address, restart the stack, and Caddy will automatically obtain and install a Let’s Encrypt TLS certificate:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Visit <code class="language-plaintext highlighter-rouge">https://files.yourcompany.com</code> and you’ll see the Nextcloud login screen with a valid HTTPS certificate. No manual cert management required.</p>

<h2 id="step-3-configure-the-apps-your-team-actually-uses">Step 3: Configure the Apps Your Team Actually Uses</h2>

<p>Nextcloud starts with just file storage enabled. The real value comes from installing the apps that replace specific Google Workspace features. After logging in as admin, go to <strong>Apps</strong> in the top-right menu and install these:</p>

<h3 id="calendar">Calendar</h3>

<p>Install the <strong>Calendar</strong> app from the Nextcloud App Store (built-in, one click). Each user gets a personal calendar, and you can create shared calendars for the team — “Company Events,” “Project Deadlines,” “Meeting Room Booking.”</p>

<p>Because Nextcloud Calendar uses CalDAV, it works with every calendar app out there:</p>
<ul>
  <li><strong>macOS Calendar:</strong> Add a CalDAV account with the URL <code class="language-plaintext highlighter-rouge">https://files.yourcompany.com/remote.php/dav</code></li>
  <li><strong>Thunderbird:</strong> Built-in CalDAV support, works natively</li>
  <li><strong>Mobile:</strong> Use <strong>DAVx⁵</strong> (Android) or the built-in CalDAV support on iOS</li>
</ul>

<p>No proprietary sync protocols. No “Google Calendar Sync” tool. Just the standard that every calendar app already understands.</p>

<h3 id="contacts">Contacts</h3>

<p>Install the <strong>Contacts</strong> app. Same story as calendar — it uses CardDAV, so it syncs with macOS Contacts, Thunderbird, iOS, and Android (via DAVx⁵). Your team’s contact list lives on your server, not in Google’s address book.</p>

<h3 id="collaborative-document-editing">Collaborative Document Editing</h3>

<p>Install <strong>Nextcloud Office</strong> (which integrates Collabora) or the <strong>OnlyOffice</strong> app. Both give you real-time collaborative editing of text documents and spreadsheets directly in the browser. Multiple people can edit the same document simultaneously, see each other’s cursors, and leave comments.</p>

<p>Collabora is the more popular choice and integrates more tightly with Nextcloud. It’s based on LibreOffice, so the interface will feel familiar to anyone who’s used LibreOffice or OpenOffice. It’s not as polished as Google Docs — the formatting tools are a bit clunkier, and some advanced features (like pivot tables in spreadsheets) are less capable. But for 90% of business documents, it does the job.</p>

<p>If your team creates a lot of documents with complex formatting, you can also use <strong>Nextcloud Text</strong> — a lightweight Markdown editor built into Nextcloud that’s excellent for meeting notes, internal documentation, and quick drafts. It’s faster and simpler than a full word processor.</p>

<h3 id="file-sharing">File Sharing</h3>

<p>File sharing is Nextcloud’s strongest feature. You can:</p>
<ul>
  <li><strong>Share files with internal users</strong> — with read-only or edit permissions, optional expiration dates</li>
  <li><strong>Share via public link</strong> — password-protected, expiration-dated, with optional upload-only mode (great for collecting files from clients)</li>
  <li><strong>Share entire folders</strong> — with fine-grained permissions per user</li>
</ul>

<p>This replaces Google Drive’s sharing model completely. The advantage is that shared links point to your domain (<code class="language-plaintext highlighter-rouge">files.yourcompany.com/s/abc123</code>) rather than Google’s, which looks more professional in client communications.</p>

<h3 id="nextcloud-talk-chat-and-video">Nextcloud Talk (Chat and Video)</h3>

<p>Install <strong>Nextcloud Talk</strong> for internal chat and video calls. It’s not a Slack replacement — it’s more like a simplified version of Google Meet plus basic chat. For a small team that needs occasional video calls and a persistent chat channel, it works. For larger teams or external collaboration, you’d want a dedicated tool (we like <strong>Mattermost</strong> for self-hosted team chat).</p>

<h2 id="step-4-migrate-your-data-off-google">Step 4: Migrate Your Data Off Google</h2>

<p>This is the part that intimidates people, but it’s more tedious than difficult. Here’s the migration path for each type of data:</p>

<h3 id="files-google-drive">Files (Google Drive)</h3>

<ol>
  <li><strong>Export from Google:</strong> Use Google Takeout (takeout.google.com) to download all your Drive files as a zip archive. Select Drive, choose your export format, and wait for Google to prepare the download.</li>
  <li><strong>Upload to Nextcloud:</strong> Unzip the archive, then either drag-and-drop files through the Nextcloud web interface (good for small amounts) or use the <strong>Nextcloud Desktop Client</strong> (better for large migrations). The sync client is available for Windows, macOS, and Linux.</li>
  <li><strong>Reorganize:</strong> Take this opportunity to clean up your folder structure. You’re moving to a new system — might as well organize it properly.</li>
</ol>

<h3 id="calendar-google-calendar">Calendar (Google Calendar)</h3>

<ol>
  <li><strong>Export from Google Calendar:</strong> Go to calendar settings → Import &amp; Export → Export. You’ll get an <code class="language-plaintext highlighter-rouge">.ics</code> file per calendar.</li>
  <li><strong>Import to Nextcloud:</strong> In Nextcloud Calendar, click “Import calendar” and upload the <code class="language-plaintext highlighter-rouge">.ics</code> files.</li>
</ol>

<h3 id="contacts-google-contacts">Contacts (Google Contacts)</h3>

<ol>
  <li><strong>Export from Google Contacts:</strong> Go to contacts.google.com → Export → choose vCard format.</li>
  <li><strong>Import to Nextcloud:</strong> In Nextcloud Contacts, click “Import” and upload the vCard file.</li>
</ol>

<h3 id="documents-google-docssheets">Documents (Google Docs/Sheets)</h3>

<p>This is the trickiest part. Google Docs documents aren’t real files — they’re web app documents stored in Google’s proprietary format. To migrate:</p>

<ol>
  <li><strong>Export each document:</strong> In Google Docs, File → Download → choose Microsoft Word (.docx) format. For spreadsheets, choose Excel (.xlsx). You can also export multiple documents at once through Google Drive (select all → right-click → Download).</li>
  <li><strong>Upload to Nextcloud:</strong> Upload the .docx/.xlsx files to Nextcloud. If you’ve installed Collabora or OnlyOffice, you can open and edit them directly in the browser with full collaborative editing.</li>
</ol>

<p>This is manual but only needs to happen once. For a team with 50-100 active documents, it’s a few hours of work. Spread across the team, each person handles their own documents.</p>

<h2 id="step-5-set-up-backups">Step 5: Set Up Backups</h2>

<p>This is the most important section in this guide. When your data was on Google’s servers, Google handled backups. Now that it’s on your server, <strong>you</strong> handle backups. This is the trade-off of self-hosting: you gain control, but you also gain responsibility.</p>

<p>At minimum, set up automated backups of two things:</p>

<ol>
  <li><strong>The database:</strong> <code class="language-plaintext highlighter-rouge">docker exec nextcloud_db pg_dump -U nextcloud nextcloud &gt; backup_$(date +%Y%m%d).sql</code></li>
  <li><strong>The file storage:</strong> The <code class="language-plaintext highlighter-rouge">./nextcloud_data</code> directory</li>
</ol>

<p>Here’s a simple backup script that runs daily, keeps 7 days of backups, and syncs them to a remote location:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># Nextcloud daily backup script</span>

<span class="nv">BACKUP_DIR</span><span class="o">=</span><span class="s2">"/backups/nextcloud"</span>
<span class="nv">NEXTCLOUD_DIR</span><span class="o">=</span><span class="s2">"/opt/nextcloud"</span>
<span class="nv">DATE</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +%Y%m%d<span class="si">)</span>
<span class="nv">RETENTION_DAYS</span><span class="o">=</span>7

<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$BACKUP_DIR</span>

<span class="c"># Backup database</span>
docker <span class="nb">exec </span>nextcloud_db pg_dump <span class="nt">-U</span> nextcloud nextcloud <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$BACKUP_DIR</span><span class="s2">/db_</span><span class="nv">$DATE</span><span class="s2">.sql"</span>

<span class="c"># Backup files (exclude cache and temp)</span>
<span class="nb">tar </span>czf <span class="s2">"</span><span class="nv">$BACKUP_DIR</span><span class="s2">/files_</span><span class="nv">$DATE</span><span class="s2">.tar.gz"</span> <span class="se">\</span>
  <span class="nt">--exclude</span><span class="o">=</span><span class="s2">"</span><span class="nv">$NEXTCLOUD_DIR</span><span class="s2">/nextcloud_data/data/*/cache"</span> <span class="se">\</span>
  <span class="nt">--exclude</span><span class="o">=</span><span class="s2">"</span><span class="nv">$NEXTCLOUD_DIR</span><span class="s2">/nextcloud_data/data/*/uploads"</span> <span class="se">\</span>
  <span class="s2">"</span><span class="nv">$NEXTCLOUD_DIR</span><span class="s2">/nextcloud_data"</span>

<span class="c"># Clean old backups</span>
find <span class="nv">$BACKUP_DIR</span> <span class="nt">-name</span> <span class="s2">"*.sql"</span> <span class="nt">-mtime</span> +<span class="nv">$RETENTION_DAYS</span> <span class="nt">-delete</span>
find <span class="nv">$BACKUP_DIR</span> <span class="nt">-name</span> <span class="s2">"*.tar.gz"</span> <span class="nt">-mtime</span> +<span class="nv">$RETENTION_DAYS</span> <span class="nt">-delete</span>

<span class="c"># Sync to remote storage (optional — use rclone with any S3-compatible provider)</span>
<span class="c"># rclone sync $BACKUP_DIR remote:nextcloud-backups/</span>
</code></pre></div></div>

<p>Set this up as a daily cron job on your server. If you want off-site backups (and you should), install <strong>rclone</strong> and sync the backup directory to an S3-compatible storage provider — we recommend <strong>Backblaze B2</strong> or <strong>Wasabi</strong> for affordable object storage. Both work with rclone out of the box.</p>

<p>Test your backups by restoring them at least once. An untested backup is not a backup — it’s a hope.</p>

<h2 id="the-real-cost-comparison">The Real Cost Comparison</h2>

<p>Let’s talk numbers for a 15-person team.</p>

<table>
  <thead>
    <tr>
      <th>Cost Category</th>
      <th>Google Workspace (Business Standard)</th>
      <th>Nextcloud (Self-Hosted)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Monthly subscription</td>
      <td>$180/mo ($12/user × 15)</td>
      <td>$0</td>
    </tr>
    <tr>
      <td>Server (4GB VPS)</td>
      <td>Included</td>
      <td>~$8/mo</td>
    </tr>
    <tr>
      <td>Backup storage (50GB)</td>
      <td>Included</td>
      <td>~$2/mo</td>
    </tr>
    <tr>
      <td>Domain name</td>
      <td>~$1/mo</td>
      <td>~$1/mo</td>
    </tr>
    <tr>
      <td>IT setup time (one-time)</td>
      <td>2-3 hours</td>
      <td>4-6 hours</td>
    </tr>
    <tr>
      <td>Maintenance (monthly)</td>
      <td>~0</td>
      <td>~1 hour</td>
    </tr>
    <tr>
      <td><strong>Year 1 total</strong></td>
      <td><strong>~$2,160</strong></td>
      <td><strong>~$132 + setup time</strong></td>
    </tr>
    <tr>
      <td><strong>Year 2+ total</strong></td>
      <td><strong>~$2,160/yr</strong></td>
      <td><strong>~$132/yr</strong></td>
    </tr>
  </tbody>
</table>

<p>The savings are real: roughly $2,000 per year for a 15-person team, scaling linearly as you add people. The trade-off is that someone needs to maintain the server — updates, backups, the occasional troubleshooting. If you have a part-time IT person or work with a consultant (we know one), this is maybe an hour per month.</p>

<p>The maintenance burden is genuinely low once it’s set up. Nextcloud updates are a single command (<code class="language-plaintext highlighter-rouge">docker compose pull &amp;&amp; docker compose up -d</code>), and the platform is mature enough that major issues are rare. The biggest risk is neglecting backups — which is why Step 5 exists.</p>

<h2 id="when-nextcloud-is-not-the-right-choice">When Nextcloud Is NOT the Right Choice</h2>

<p>Being honest: Nextcloud isn’t for everyone. Here’s when you should stick with a hosted solution:</p>

<ul>
  <li><strong>You have no one to maintain a server.</strong> If your team is entirely non-technical and you don’t have an IT consultant, the self-hosting model adds risk. A managed Nextcloud provider (like Nextcloud’s official hosting partners) is a middle ground — you get the open source platform without the server management.</li>
  <li><strong>You rely heavily on Google Docs’ advanced features.</strong> If your team builds complex spreadsheets with pivot tables, apps script, and heavy formatting, Collabora/OnlyOffice will feel limiting. For most business documents it’s fine, but power users will notice the difference.</li>
  <li><strong>You need deep integration with third-party apps that assume Google Workspace.</strong> Some tools only offer “Sign in with Google” and Google Drive integration. If your workflow depends on these, switching requires finding alternatives or accepting manual workarounds.</li>
</ul>

<p>For most small businesses — especially those whose file and calendar needs are straightforward — Nextcloud is a genuinely good replacement that saves money and gives you ownership of your data.</p>

<h2 id="making-the-switch-gradually">Making the Switch Gradually</h2>

<p>You don’t have to migrate everything in one weekend. A phased approach works well:</p>

<ol>
  <li><strong>Week 1:</strong> Set up Nextcloud, configure HTTPS, create user accounts. Migrate calendar and contacts (fast, low-risk).</li>
  <li><strong>Week 2-3:</strong> Migrate files. Start with the current active project files, then archive older files in batches.</li>
  <li><strong>Week 4:</strong> Set up Collabora for document editing. Migrate active documents.</li>
  <li><strong>Week 5+:</strong> Stop creating new content in Google Workspace. Keep Google Workspace active for a month as a safety net, then cancel.</li>
</ol>

<p>This gives your team time to adjust and ensures nothing critical gets lost in the transition. By the end of the second month, you’re fully on Nextcloud and saving ~$180/month.</p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>Google Workspace is convenient, but convenience isn’t the same as control. Every file you store in Google Drive is a file you’re renting access to rather than owning. Every calendar event in Google Calendar is data that lives on Google’s terms, not yours. Nextcloud gives you the same core capabilities — file sync, calendar, contacts, collaborative editing — on infrastructure you control, for a fraction of the cost.</p>

<p>The setup takes an afternoon. The migration takes a few weeks of gradual transition. The payoff is permanent: your company’s knowledge stays on your server, under your rules, with no per-user tax and no vendor deciding to change the deal next quarter.</p>

<p>If you want help setting up Nextcloud for your business — from server configuration to data migration to team training — <a href="/#contact">get in touch</a>. We specialize in helping small businesses move to self-hosted open source tools without the headaches.</p>]]></content><author><name>ARDOT Consulting</name></author><category term="nextcloud" /><category term="self-hosting" /><category term="file-sync" /><category term="collaboration" /><category term="open-source" /><category term="docker" /><category term="google-workspace-replacement" /><summary type="html"><![CDATA[Google Workspace costs $7–$14 per user per month and locks your company's files in someone else's cloud. Here's how to replace it with Nextcloud — a self-hosted platform for file sync, calendar, contacts, and document collaboration that you fully control.]]></summary></entry><entry><title type="html">Building an Internal Knowledge Base with BookStack and AI: Make Company Knowledge Searchable</title><link href="https://www.ardotconsulting.com/blog/2026/10/04/building-an-internal-knowledge-base-with-bookstack-and-ai/" rel="alternate" type="text/html" title="Building an Internal Knowledge Base with BookStack and AI: Make Company Knowledge Searchable" /><published>2026-10-04T00:00:00-07:00</published><updated>2026-10-04T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/10/04/building-an-internal-knowledge-base-with-bookstack-and-ai</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/10/04/building-an-internal-knowledge-base-with-bookstack-and-ai/"><![CDATA[<p>Your company has a knowledge problem. You know it. Your team knows it. The answer to last week’s customer question is sitting in someone’s email, a Slack thread from March, a Google Doc that nobody can find, and a sticky note on Sandra’s monitor. You’ve tried shared folders. You’ve tried a wiki. Nobody updates the wiki, and the search bar returns nothing useful because nobody remembers the exact title of the page they’re looking for.</p>

<p>The fix isn’t another SaaS subscription. It’s a self-hosted knowledge base paired with AI-powered search that understands what your team is actually asking — not just keyword matching, but semantic search that finds “how do I process a refund” when the document is titled “Customer Return Policy.”</p>

<p>In this guide, we’ll set up <strong>BookStack</strong>, an open-source wiki platform, and pair it with <strong>Ollama</strong> for AI-powered semantic search. Everything runs on your own infrastructure. No per-seat fees, no data leaving your network, no vendor deciding to double their pricing next quarter.</p>

<h2 id="why-bookstack">Why BookStack?</h2>

<p>BookStack is a free, open-source documentation platform that sits somewhere between a wiki and a structured documentation site. It’s built in PHP/Laravel, runs in Docker, and has a clean, approachable interface that doesn’t scare non-technical staff.</p>

<p>Here’s why it works for small businesses:</p>

<ul>
  <li><strong>Organized by default:</strong> Content is structured into Books → Chapters → Pages, so your team isn’t dumping everything into one flat list</li>
  <li><strong>WYSIWYG editor:</strong> No Markdown required (though it’s supported). Your operations manager can actually use it</li>
  <li><strong>Role-based permissions:</strong> Control who can view, edit, or administer different books</li>
  <li><strong>Search built in:</strong> Full-text search out of the box — we’ll make it smarter with AI</li>
  <li><strong>Self-hosted:</strong> Runs on a $5/month VPS or an old office desktop</li>
</ul>

<h3 id="how-bookstack-compares-to-saas-alternatives">How BookStack Compares to SaaS Alternatives</h3>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>BookStack (Self-Hosted)</th>
      <th>Notion (SaaS)</th>
      <th>Confluence (SaaS)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cost</td>
      <td>Free (server cost only)</td>
      <td>$8–$18/user/month</td>
      <td>$5–$10/user/month</td>
    </tr>
    <tr>
      <td>Data location</td>
      <td>Your server</td>
      <td>Vendor’s cloud</td>
      <td>Vendor’s cloud</td>
    </tr>
    <tr>
      <td>Offline access</td>
      <td>Yes (on your network)</td>
      <td>Limited</td>
      <td>Limited</td>
    </tr>
    <tr>
      <td>User limit</td>
      <td>Unlimited</td>
      <td>Plan-based</td>
      <td>Plan-based</td>
    </tr>
    <tr>
      <td>Customization</td>
      <td>Full source code</td>
      <td>Limited</td>
      <td>Limited</td>
    </tr>
    <tr>
      <td>Export format</td>
      <td>HTML, PDF, Markdown</td>
      <td>Limited</td>
      <td>Limited</td>
    </tr>
    <tr>
      <td>AI search</td>
      <td>Add your own (this guide)</td>
      <td>Vendor-controlled</td>
      <td>Vendor-controlled</td>
    </tr>
  </tbody>
</table>

<p>For a 15-person team, that’s $1,440–$3,240 per year in SaaS fees you’re not paying. Plus, your company knowledge stays on your server.</p>

<h2 id="step-1-set-up-bookstack-with-docker">Step 1: Set Up BookStack with Docker</h2>

<p>You’ll need a server with Docker installed. This can be a VPS from Hetzner, OVH, or any provider you trust — or a spare machine in your office. We’ll use the official BookStack Docker image.</p>

<p>Create a <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">bookstack</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/bookstack:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">bookstack</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">PUID=1000</span>
      <span class="pi">-</span> <span class="s">PGID=1000</span>
      <span class="pi">-</span> <span class="s">TZ=America/New_York</span>
      <span class="pi">-</span> <span class="s">DB_HOST=bookstack_db</span>
      <span class="pi">-</span> <span class="s">DB_USER=bookstack</span>
      <span class="pi">-</span> <span class="s">DB_PASS=change_this_password</span>
      <span class="pi">-</span> <span class="s">DB_DATABASE=bookstack</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./bookstack_data:/config</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">6875:80</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">bookstack_db</span>

  <span class="na">bookstack_db</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/mariadb:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">bookstack_db</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">PUID=1000</span>
      <span class="pi">-</span> <span class="s">PGID=1000</span>
      <span class="pi">-</span> <span class="s">TZ=America/New_York</span>
      <span class="pi">-</span> <span class="s">MYSQL_ROOT_PASSWORD=change_this_root_password</span>
      <span class="pi">-</span> <span class="s">MYSQL_DATABASE=bookstack</span>
      <span class="pi">-</span> <span class="s">MYSQL_USER=bookstack</span>
      <span class="pi">-</span> <span class="s">MYSQL_PASSWORD=change_this_password</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./db_data:/config</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
</code></pre></div></div>

<p>Start it up:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Wait about 30 seconds for the database to initialize, then open <code class="language-plaintext highlighter-rouge">http://your-server-ip:6875</code> in your browser. The default login is <code class="language-plaintext highlighter-rouge">admin@admin.com</code> / <code class="language-plaintext highlighter-rouge">password</code>. <strong>Change this immediately</strong> on first login.</p>

<h3 id="secure-it-with-a-reverse-proxy">Secure It with a Reverse Proxy</h3>

<p>For production, don’t expose port 6875 directly. Use Caddy as a reverse proxy with automatic HTTPS:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Add to your docker-compose.yml</span>
  <span class="na">caddy</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">caddy:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">caddy</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">80:80</span>
      <span class="pi">-</span> <span class="s">443:443</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./Caddyfile:/etc/caddy/Caddyfile</span>
      <span class="pi">-</span> <span class="s">caddy_data:/data</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">bookstack</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">caddy_data</span><span class="pi">:</span>
</code></pre></div></div>

<p>And a minimal <code class="language-plaintext highlighter-rouge">Caddyfile</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wiki.yourcompany.com {
    reverse_proxy bookstack:80
}
</code></pre></div></div>

<p>Point your DNS to the server, restart with <code class="language-plaintext highlighter-rouge">docker compose up -d</code>, and Caddy handles the TLS certificate automatically. Your team now accesses the wiki at <code class="language-plaintext highlighter-rouge">https://wiki.yourcompany.com</code>.</p>

<h2 id="step-2-organize-your-knowledge">Step 2: Organize Your Knowledge</h2>

<p>Before adding AI search, you need content worth searching. The biggest reason internal wikis fail is that nobody structures them. BookStack’s hierarchy helps, but you still need a plan.</p>

<p>Here’s a structure that works for most small businesses:</p>

<p><strong>Book 1: Company Operations</strong></p>
<ul>
  <li>Chapter: Onboarding → Pages: Day 1 Checklist, Tools Setup, Key Contacts</li>
  <li>Chapter: Daily Procedures → Pages: Opening/Closing, Cash Handling, End-of-Day Reports</li>
  <li>Chapter: Emergency Procedures → Pages: System Outage, Customer Incident, Evacuation</li>
</ul>

<p><strong>Book 2: Customer Guide</strong></p>
<ul>
  <li>Chapter: Common Questions → Pages: Return Policy, Warranty Claims, Pricing Tiers</li>
  <li>Chapter: Account Management → Pages: How to Create Accounts, Reset Passwords, Update Billing</li>
</ul>

<p><strong>Book 3: Internal Tools</strong></p>
<ul>
  <li>Chapter: Systems → Pages: CRM Login, Email Setup, VPN Access</li>
  <li>Chapter: Troubleshooting → Pages: Printer Issues, Email Problems, VPN Won’t Connect</li>
</ul>

<p>The key principle: <strong>every page should answer one question.</strong> Not “Everything About Customer Service” but “How to Process a Refund.” This makes both human search and AI search more effective.</p>

<h2 id="step-3-add-ai-powered-semantic-search-with-ollama">Step 3: Add AI-Powered Semantic Search with Ollama</h2>

<p>BookStack’s built-in search is decent — it does full-text matching. But it has the same limitation as every keyword search: if a user types “how do I handle a customer who wants their money back,” it won’t find a page titled “Refund Processing Procedure” unless those exact words appear.</p>

<p>Semantic search fixes this. Instead of matching keywords, it matches <em>meaning</em>. We’ll use Ollama to generate embeddings (numerical representations of text meaning) for every page in your wiki, then search against those embeddings.</p>

<h3 id="install-ollama">Install Ollama</h3>

<p>If you followed our <a href="/blog/2026/08/20/running-local-llms-with-ollama-a-practical-guide-for-businesses/">Ollama guide</a>, you already have it. If not:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsSL</span> https://ollama.com/install.sh | sh
</code></pre></div></div>

<p>Pull an embedding model — these are small and fast:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama pull nomic-embed-text
</code></pre></div></div>

<p>This model is about 270MB and runs on any machine with 4GB RAM. It doesn’t need a GPU for embedding generation.</p>

<h3 id="build-a-search-index">Build a Search Index</h3>

<p>We’ll write a Python script that:</p>

<ol>
  <li>Pulls all pages from BookStack’s REST API</li>
  <li>Generates an embedding for each page</li>
  <li>Stores embeddings in a simple SQLite database</li>
  <li>Provides a search endpoint that compares query embeddings against stored ones</li>
</ol>

<p>First, get an API token from BookStack: Settings → API Tokens → Create Token. Save the ID and secret.</p>

<p>Here’s the indexing script, <code class="language-plaintext highlighter-rouge">build_index.py</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">requests</span>
<span class="kn">import</span> <span class="n">json</span>
<span class="kn">import</span> <span class="n">sqlite3</span>
<span class="kn">import</span> <span class="n">os</span>
<span class="kn">import</span> <span class="n">subprocess</span>

<span class="c1"># BookStack API config
</span><span class="n">BOOKSTACK_URL</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">BOOKSTACK_URL</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">http://localhost:6875</span><span class="sh">"</span><span class="p">)</span>
<span class="n">API_TOKEN_ID</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">BS_API_ID</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">your_token_id</span><span class="sh">"</span><span class="p">)</span>
<span class="n">API_TOKEN_SECRET</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">BS_API_SECRET</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">your_token_secret</span><span class="sh">"</span><span class="p">)</span>

<span class="c1"># Ollama config
</span><span class="n">OLLAMA_URL</span> <span class="o">=</span> <span class="sh">"</span><span class="s">http://localhost:11434</span><span class="sh">"</span>
<span class="n">EMBED_MODEL</span> <span class="o">=</span> <span class="sh">"</span><span class="s">nomic-embed-text</span><span class="sh">"</span>

<span class="k">def</span> <span class="nf">get_all_pages</span><span class="p">():</span>
    <span class="sh">"""</span><span class="s">Fetch all pages from BookStack API.</span><span class="sh">"""</span>
    <span class="n">headers</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">Authorization</span><span class="sh">"</span><span class="p">:</span> <span class="sa">f</span><span class="sh">"</span><span class="s">Token </span><span class="si">{</span><span class="n">API_TOKEN_ID</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">API_TOKEN_SECRET</span><span class="si">}</span><span class="sh">"</span><span class="p">}</span>
    <span class="n">pages</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">offset</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
        <span class="n">resp</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span>
            <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">BOOKSTACK_URL</span><span class="si">}</span><span class="s">/api/pages</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">,</span>
            <span class="n">params</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">count</span><span class="sh">"</span><span class="p">:</span> <span class="mi">100</span><span class="p">,</span> <span class="sh">"</span><span class="s">offset</span><span class="sh">"</span><span class="p">:</span> <span class="n">offset</span><span class="p">}</span>
        <span class="p">)</span>
        <span class="n">resp</span><span class="p">.</span><span class="nf">raise_for_status</span><span class="p">()</span>
        <span class="n">data</span> <span class="o">=</span> <span class="n">resp</span><span class="p">.</span><span class="nf">json</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">data</span><span class="sh">"</span><span class="p">,</span> <span class="p">[])</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">data</span><span class="p">:</span>
            <span class="k">break</span>
        <span class="n">pages</span><span class="p">.</span><span class="nf">extend</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
        <span class="n">offset</span> <span class="o">+=</span> <span class="mi">100</span>
    <span class="k">return</span> <span class="n">pages</span>

<span class="k">def</span> <span class="nf">get_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Generate embedding for text using Ollama.</span><span class="sh">"""</span>
    <span class="n">resp</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span>
        <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">OLLAMA_URL</span><span class="si">}</span><span class="s">/api/embeddings</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">model</span><span class="sh">"</span><span class="p">:</span> <span class="n">EMBED_MODEL</span><span class="p">,</span> <span class="sh">"</span><span class="s">prompt</span><span class="sh">"</span><span class="p">:</span> <span class="n">text</span><span class="p">[:</span><span class="mi">8000</span><span class="p">]}</span>
    <span class="p">)</span>
    <span class="n">resp</span><span class="p">.</span><span class="nf">raise_for_status</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">resp</span><span class="p">.</span><span class="nf">json</span><span class="p">()[</span><span class="sh">"</span><span class="s">embedding</span><span class="sh">"</span><span class="p">]</span>

<span class="k">def</span> <span class="nf">init_db</span><span class="p">(</span><span class="n">db_path</span><span class="o">=</span><span class="sh">"</span><span class="s">search_index.db</span><span class="sh">"</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Initialize SQLite database for embeddings.</span><span class="sh">"""</span>
    <span class="n">conn</span> <span class="o">=</span> <span class="n">sqlite3</span><span class="p">.</span><span class="nf">connect</span><span class="p">(</span><span class="n">db_path</span><span class="p">)</span>
    <span class="n">conn</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="sh">"""</span><span class="s">
        CREATE TABLE IF NOT EXISTS page_embeddings (
            page_id INTEGER PRIMARY KEY,
            title TEXT,
            url TEXT,
            content_preview TEXT,
            embedding TEXT
        )
    </span><span class="sh">"""</span><span class="p">)</span>
    <span class="n">conn</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">conn</span>

<span class="k">def</span> <span class="nf">build_index</span><span class="p">():</span>
    <span class="sh">"""</span><span class="s">Build the search index from all BookStack pages.</span><span class="sh">"""</span>
    <span class="n">conn</span> <span class="o">=</span> <span class="nf">init_db</span><span class="p">()</span>
    <span class="n">pages</span> <span class="o">=</span> <span class="nf">get_all_pages</span><span class="p">()</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Found </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">pages</span><span class="p">)</span><span class="si">}</span><span class="s"> pages to index</span><span class="sh">"</span><span class="p">)</span>

    <span class="k">for</span> <span class="n">page</span> <span class="ow">in</span> <span class="n">pages</span><span class="p">:</span>
        <span class="c1"># Combine title and text content for richer embeddings
</span>        <span class="n">text</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">name</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">)</span><span class="si">}</span><span class="se">\n</span><span class="si">{</span><span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">text</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">)</span><span class="si">}</span><span class="sh">"</span>
        <span class="n">embedding</span> <span class="o">=</span> <span class="nf">get_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>

        <span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">BOOKSTACK_URL</span><span class="si">}</span><span class="s">/books/</span><span class="si">{</span><span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">book_slug</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">)</span><span class="si">}</span><span class="s">/page/</span><span class="si">{</span><span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">slug</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">)</span><span class="si">}</span><span class="sh">"</span>

        <span class="n">conn</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span>
            <span class="sh">"</span><span class="s">INSERT OR REPLACE INTO page_embeddings VALUES (?, ?, ?, ?, ?)</span><span class="sh">"</span><span class="p">,</span>
            <span class="p">(</span>
                <span class="n">page</span><span class="p">[</span><span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">],</span>
                <span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">),</span>
                <span class="n">url</span><span class="p">,</span>
                <span class="n">text</span><span class="p">[:</span><span class="mi">200</span><span class="p">],</span>
                <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span><span class="n">embedding</span><span class="p">)</span>
            <span class="p">)</span>
        <span class="p">)</span>
        <span class="n">conn</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">  Indexed: </span><span class="si">{</span><span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">name</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">)</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

    <span class="n">conn</span><span class="p">.</span><span class="nf">close</span><span class="p">()</span>
    <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Index build complete</span><span class="sh">"</span><span class="p">)</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="nf">build_index</span><span class="p">()</span>
</code></pre></div></div>

<p>Run it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>requests
<span class="nb">export </span><span class="nv">BS_API_ID</span><span class="o">=</span><span class="s2">"your_token_id"</span>
<span class="nb">export </span><span class="nv">BS_API_SECRET</span><span class="o">=</span><span class="s2">"your_token_secret"</span>
python build_index.py
</code></pre></div></div>

<h3 id="build-the-search-interface">Build the Search Interface</h3>

<p>Now create a small Flask app that takes a natural-language query and returns the most relevant pages:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># search_server.py
</span><span class="kn">from</span> <span class="n">flask</span> <span class="kn">import</span> <span class="n">Flask</span><span class="p">,</span> <span class="n">request</span><span class="p">,</span> <span class="n">jsonify</span>
<span class="kn">import</span> <span class="n">requests</span>
<span class="kn">import</span> <span class="n">json</span>
<span class="kn">import</span> <span class="n">sqlite3</span>
<span class="kn">import</span> <span class="n">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="n">app</span> <span class="o">=</span> <span class="nc">Flask</span><span class="p">(</span><span class="n">__name__</span><span class="p">)</span>
<span class="n">OLLAMA_URL</span> <span class="o">=</span> <span class="sh">"</span><span class="s">http://localhost:11434</span><span class="sh">"</span>
<span class="n">EMBED_MODEL</span> <span class="o">=</span> <span class="sh">"</span><span class="s">nomic-embed-text</span><span class="sh">"</span>

<span class="k">def</span> <span class="nf">get_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
    <span class="n">resp</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span>
        <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">OLLAMA_URL</span><span class="si">}</span><span class="s">/api/embeddings</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">model</span><span class="sh">"</span><span class="p">:</span> <span class="n">EMBED_MODEL</span><span class="p">,</span> <span class="sh">"</span><span class="s">prompt</span><span class="sh">"</span><span class="p">:</span> <span class="n">text</span><span class="p">}</span>
    <span class="p">)</span>
    <span class="n">resp</span><span class="p">.</span><span class="nf">raise_for_status</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="nf">array</span><span class="p">(</span><span class="n">resp</span><span class="p">.</span><span class="nf">json</span><span class="p">()[</span><span class="sh">"</span><span class="s">embedding</span><span class="sh">"</span><span class="p">])</span>

<span class="k">def</span> <span class="nf">cosine_similarity</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="nf">dot</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="nf">norm</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="nf">norm</span><span class="p">(</span><span class="n">b</span><span class="p">))</span>

<span class="nd">@app.route</span><span class="p">(</span><span class="sh">"</span><span class="s">/search</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">search</span><span class="p">():</span>
    <span class="n">query</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">q</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">query</span><span class="p">:</span>
        <span class="k">return</span> <span class="nf">jsonify</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">No query provided</span><span class="sh">"</span><span class="p">}),</span> <span class="mi">400</span>

    <span class="n">query_embedding</span> <span class="o">=</span> <span class="nf">get_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>

    <span class="n">conn</span> <span class="o">=</span> <span class="n">sqlite3</span><span class="p">.</span><span class="nf">connect</span><span class="p">(</span><span class="sh">"</span><span class="s">search_index.db</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">conn</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="sh">"</span><span class="s">SELECT page_id, title, url, content_preview, embedding FROM page_embeddings</span><span class="sh">"</span><span class="p">):</span>
        <span class="n">stored_embedding</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">array</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">row</span><span class="p">[</span><span class="mi">4</span><span class="p">]))</span>
        <span class="n">score</span> <span class="o">=</span> <span class="nf">cosine_similarity</span><span class="p">(</span><span class="n">query_embedding</span><span class="p">,</span> <span class="n">stored_embedding</span><span class="p">)</span>
        <span class="n">results</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span>
            <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">row</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span>
            <span class="sh">"</span><span class="s">url</span><span class="sh">"</span><span class="p">:</span> <span class="n">row</span><span class="p">[</span><span class="mi">2</span><span class="p">],</span>
            <span class="sh">"</span><span class="s">preview</span><span class="sh">"</span><span class="p">:</span> <span class="n">row</span><span class="p">[</span><span class="mi">3</span><span class="p">],</span>
            <span class="sh">"</span><span class="s">score</span><span class="sh">"</span><span class="p">:</span> <span class="nf">float</span><span class="p">(</span><span class="n">score</span><span class="p">)</span>
        <span class="p">})</span>
    <span class="n">conn</span><span class="p">.</span><span class="nf">close</span><span class="p">()</span>

    <span class="c1"># Sort by similarity score, return top 5
</span>    <span class="n">results</span><span class="p">.</span><span class="nf">sort</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="sh">"</span><span class="s">score</span><span class="sh">"</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="k">return</span> <span class="nf">jsonify</span><span class="p">({</span><span class="sh">"</span><span class="s">results</span><span class="sh">"</span><span class="p">:</span> <span class="n">results</span><span class="p">[:</span><span class="mi">5</span><span class="p">]})</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="n">host</span><span class="o">=</span><span class="sh">"</span><span class="s">0.0.0.0</span><span class="sh">"</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">5000</span><span class="p">)</span>
</code></pre></div></div>

<p>Run the search server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>flask numpy
python search_server.py
</code></pre></div></div>

<p>Now you can search:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s2">"http://localhost:5000/search?q=how+do+I+process+a+refund"</span>
</code></pre></div></div>

<p>And get back the most semantically relevant pages — even if none of them contain the exact word “refund.” The search understands that “process a refund” and “customer return policy” are talking about the same thing.</p>

<h3 id="rebuild-the-index-when-content-changes">Rebuild the Index When Content Changes</h3>

<p>BookStack content evolves. Set up a cron job to rebuild the index nightly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/cron.d/bookstack-search</span>
0 2 <span class="k">*</span> <span class="k">*</span> <span class="k">*</span> root <span class="nv">BS_API_ID</span><span class="o">=</span>your_token_id <span class="nv">BS_API_SECRET</span><span class="o">=</span>your_token_secret /usr/bin/python3 /opt/bookstack-search/build_index.py <span class="o">&gt;&gt;</span> /var/log/bookstack-search.log 2&gt;&amp;1
</code></pre></div></div>

<p>Or, for real-time updates, use BookStack’s webhook feature to trigger a re-index of a single page whenever content is edited. That’s more work but keeps the index always current.</p>

<h2 id="step-4-make-it-usable-for-your-team">Step 4: Make It Usable for Your Team</h2>

<p>A search API is great for developers but useless for your operations manager. You need a front end. Here are two approaches:</p>

<h3 id="option-a-simple-html-page">Option A: Simple HTML Page</h3>

<p>A single-page HTML form that calls your search API:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span><span class="nt">&gt;</span>
<span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"UTF-8"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;title&gt;</span>Company Knowledge Search<span class="nt">&lt;/title&gt;</span>
    <span class="nt">&lt;style&gt;</span>
        <span class="nt">body</span> <span class="p">{</span> <span class="nl">font-family</span><span class="p">:</span> <span class="nb">sans-serif</span><span class="p">;</span> <span class="nl">max-width</span><span class="p">:</span> <span class="m">700px</span><span class="p">;</span> <span class="nl">margin</span><span class="p">:</span> <span class="m">40px</span> <span class="nb">auto</span><span class="p">;</span> <span class="nl">padding</span><span class="p">:</span> <span class="m">0</span> <span class="m">20px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.search-box</span> <span class="p">{</span> <span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span> <span class="nl">padding</span><span class="p">:</span> <span class="m">12px</span><span class="p">;</span> <span class="nl">font-size</span><span class="p">:</span> <span class="m">16px</span><span class="p">;</span> <span class="nl">border</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="nx">#ddd</span><span class="p">;</span> <span class="nl">border-radius</span><span class="p">:</span> <span class="m">6px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.result</span> <span class="p">{</span> <span class="nl">padding</span><span class="p">:</span> <span class="m">16px</span> <span class="m">0</span><span class="p">;</span> <span class="nl">border-bottom</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="nx">#eee</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.result</span> <span class="nt">h3</span> <span class="p">{</span> <span class="nl">margin</span><span class="p">:</span> <span class="m">0</span> <span class="m">0</span> <span class="m">4px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.result</span> <span class="nt">h3</span> <span class="nt">a</span> <span class="p">{</span> <span class="nl">color</span><span class="p">:</span> <span class="nx">#2196F3</span><span class="p">;</span> <span class="nl">text-decoration</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.result</span> <span class="nc">.preview</span> <span class="p">{</span> <span class="nl">color</span><span class="p">:</span> <span class="nx">#666</span><span class="p">;</span> <span class="nl">font-size</span><span class="p">:</span> <span class="m">14px</span><span class="p">;</span> <span class="nl">margin-top</span><span class="p">:</span> <span class="m">4px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.score</span> <span class="p">{</span> <span class="nl">color</span><span class="p">:</span> <span class="nx">#999</span><span class="p">;</span> <span class="nl">font-size</span><span class="p">:</span> <span class="m">12px</span><span class="p">;</span> <span class="p">}</span>
    <span class="nt">&lt;/style&gt;</span>
<span class="nt">&lt;/head&gt;</span>
<span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;h1&gt;</span>🔍 Knowledge Search<span class="nt">&lt;/h1&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">class=</span><span class="s">"search-box"</span> <span class="na">id=</span><span class="s">"query"</span> <span class="na">placeholder=</span><span class="s">"Ask anything..."</span> <span class="na">onkeyup=</span><span class="s">"search(event)"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"results"</span><span class="nt">&gt;&lt;/div&gt;</span>

    <span class="nt">&lt;script&gt;</span>
        <span class="k">async</span> <span class="kd">function</span> <span class="nf">search</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">if </span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">key</span> <span class="o">!==</span> <span class="dl">'</span><span class="s1">Enter</span><span class="dl">'</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
            <span class="kd">const</span> <span class="nx">q</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">query</span><span class="dl">'</span><span class="p">).</span><span class="nx">value</span><span class="p">;</span>
            <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">q</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
            <span class="kd">const</span> <span class="nx">resp</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="s2">`http://your-server:5000/search?q=</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">q</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
            <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">resp</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>
            <span class="kd">const</span> <span class="nx">html</span> <span class="o">=</span> <span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">results</span> <span class="o">||</span> <span class="p">[]).</span><span class="nx">map</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="s2">`
                &lt;div class="result"&gt;
                    &lt;h3&gt;&lt;a href="</span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">url</span><span class="p">}</span><span class="s2">" target="_blank"&gt;</span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">title</span><span class="p">}</span><span class="s2">&lt;/a&gt;&lt;/h3&gt;
                    &lt;div class="preview"&gt;</span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">preview</span><span class="p">}</span><span class="s2">&lt;/div&gt;
                    &lt;div class="score"&gt;Match: </span><span class="p">${(</span><span class="nx">r</span><span class="p">.</span><span class="nx">score</span> <span class="o">*</span> <span class="mi">100</span><span class="p">).</span><span class="nf">toFixed</span><span class="p">(</span><span class="mi">0</span><span class="p">)}</span><span class="s2">%&lt;/div&gt;
                &lt;/div&gt;
            `</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="dl">''</span><span class="p">);</span>
            <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">results</span><span class="dl">'</span><span class="p">).</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="nx">html</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>Host this on your Caddy server alongside BookStack, and your team has a simple search bar that understands natural language.</p>

<h3 id="option-b-integrate-with-your-chat-platform">Option B: Integrate with Your Chat Platform</h3>

<p>If your team uses Mattermost (open-source Slack alternative), you can create a bot that responds to <code class="language-plaintext highlighter-rouge">/search how do I process a refund</code> with the top results directly in chat. This is where the search gets the most use — people are already in chat when they have a question.</p>

<p>The bot is a simple Python script that listens for slash commands and queries your search API:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">mattermost</span> <span class="kn">import</span> <span class="n">MattermostAPI</span>
<span class="kn">import</span> <span class="n">requests</span>

<span class="n">mm</span> <span class="o">=</span> <span class="nc">MattermostAPI</span><span class="p">(</span><span class="sh">"</span><span class="s">https://chat.yourcompany.com</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">your_bot_token</span><span class="sh">"</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">handle_search_command</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">):</span>
    <span class="n">resp</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span>
        <span class="sh">"</span><span class="s">http://localhost:5000/search</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">params</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">q</span><span class="sh">"</span><span class="p">:</span> <span class="n">query</span><span class="p">}</span>
    <span class="p">)</span>
    <span class="n">results</span> <span class="o">=</span> <span class="n">resp</span><span class="p">.</span><span class="nf">json</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">results</span><span class="sh">"</span><span class="p">,</span> <span class="p">[])</span>

    <span class="k">if</span> <span class="ow">not</span> <span class="n">results</span><span class="p">:</span>
        <span class="n">mm</span><span class="p">.</span><span class="nf">create_post</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="sh">"</span><span class="s">No results found. Try different wording.</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span>

    <span class="n">message</span> <span class="o">=</span> <span class="sh">"</span><span class="s">**Top results:**</span><span class="se">\n\n</span><span class="sh">"</span>
    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">r</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">results</span><span class="p">[:</span><span class="mi">3</span><span class="p">],</span> <span class="mi">1</span><span class="p">):</span>
        <span class="n">message</span> <span class="o">+=</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">. [</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="s">](</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="sh">'</span><span class="s">url</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="s">)</span><span class="se">\n</span><span class="s">   _</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="sh">'</span><span class="s">preview</span><span class="sh">'</span><span class="p">][</span><span class="si">:</span><span class="mi">100</span><span class="p">]</span><span class="si">}</span><span class="s">..._</span><span class="se">\n</span><span class="sh">"</span>

    <span class="n">mm</span><span class="p">.</span><span class="nf">create_post</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="real-world-impact">Real-World Impact</h2>

<p>Here’s what this looks like in practice for a small business:</p>

<p><strong>Before:</strong> A new hire needs to know how to process a return. They search the shared drive for “return” and get 47 files. They ask three colleagues. One says “check the wiki.” The wiki search returns nothing because the page is called “Customer Refund Workflow.” Twenty minutes lost.</p>

<p><strong>After:</strong> The new hire types “how do I handle a customer return” into the knowledge search. The AI matches the semantic meaning to “Customer Refund Workflow” and returns it as the top result with a 94% match score. They click through and have their answer in 30 seconds.</p>

<p>Multiply that by 15 employees asking 5 questions per day, and you’re saving roughly 18 hours of “where do I find…” time per week. That’s nearly half a full-time position redirected from searching to actually working.</p>

<h2 id="keeping-it-alive">Keeping It Alive</h2>

<p>The biggest threat to any knowledge base isn’t technology — it’s entropy. Here’s how to keep yours from going stale:</p>

<ol>
  <li><strong>Assign ownership:</strong> Each Book (Company Operations, Customer Guide, etc.) has one person responsible for reviewing it monthly</li>
  <li><strong>Page review dates:</strong> BookStack doesn’t have this natively, but you can add a “Last Reviewed: [date]” line at the top of each page and set a calendar reminder</li>
  <li><strong>Tie updates to process changes:</strong> When you change a business process, updating the wiki page is part of the change — not a separate task</li>
  <li><strong>Watch the search logs:</strong> If people search for something and get no results, that’s a gap. Create a page for it</li>
  <li><strong>Re-index regularly:</strong> The nightly cron job handles this, but verify it’s running: <code class="language-plaintext highlighter-rouge">tail -20 /var/log/bookstack-search.log</code></li>
</ol>

<h2 id="what-this-setup-costs-you">What This Setup Costs You</h2>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>BookStack (open source)</td>
      <td>$0</td>
    </tr>
    <tr>
      <td>Ollama + nomic-embed-text</td>
      <td>$0</td>
    </tr>
    <tr>
      <td>VPS (4GB RAM, 2 vCPU)</td>
      <td>~$5–$10/month</td>
    </tr>
    <tr>
      <td>Domain name</td>
      <td>~$10/year</td>
    </tr>
    <tr>
      <td>Caddy (reverse proxy + TLS)</td>
      <td>$0</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>~$70–$130/year</strong></td>
    </tr>
  </tbody>
</table>

<p>Compare that to Notion at $1,440/year for 15 users, or Confluence at $600–$1,800/year. And your data never leaves your server.</p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>A knowledge base only works if people can find what they need. Traditional wikis fail because their search is keyword-based and people don’t think in keywords — they think in questions. By combining BookStack’s clean documentation structure with Ollama’s semantic search, you give your team a system that understands what they’re actually asking for.</p>

<p>The whole setup runs on infrastructure you control, costs less than a single SaaS seat per month, and scales to as many users as you have. The only ongoing work is keeping your content current — and that’s a people problem, not a technology one.</p>

<p>If you want help setting up a self-hosted knowledge base with AI search for your business, <a href="/#contact">reach out through our contact form</a>. We’ll assess your needs, handle the setup, and train your team on keeping it alive — no SaaS subscriptions required.</p>]]></content><author><name>ARDOT Consulting</name></author><category term="knowledge-base" /><category term="bookstack" /><category term="ollama" /><category term="search" /><category term="open-source" /><category term="docker" /><summary type="html"><![CDATA[Learn how to set up BookStack as a self-hosted company wiki and add AI-powered semantic search with Ollama so your team actually finds what they need.]]></summary></entry><entry><title type="html">Going Paperless with Paperless-ngx: Open-Source Document Management for Your Business</title><link href="https://www.ardotconsulting.com/blog/2026/10/02/going-paperless-with-paperless-ngx-open-source-document-management/" rel="alternate" type="text/html" title="Going Paperless with Paperless-ngx: Open-Source Document Management for Your Business" /><published>2026-10-02T00:00:00-07:00</published><updated>2026-10-02T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/10/02/going-paperless-with-paperless-ngx-open-source-document-management</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/10/02/going-paperless-with-paperless-ngx-open-source-document-management/"><![CDATA[<p>Every business produces documents. Invoices from vendors. Signed contracts. Receipts. Tax forms. Insurance certificates. Employee onboarding paperwork. Utility bills. Bank statements. They arrive as paper in the mail, as PDF attachments in email, as scanned images from a multifunction printer — and they all end up in the same place: a filing cabinet, a shared network drive, or worse, someone’s desktop folder named “Stuff to deal with later.”</p>

<p>You know the feeling. You need a specific invoice from eight months ago, and you spend forty-five minutes searching through folders, email threads, and physical binders before finally finding it — or giving up and asking the vendor to resend it. That’s not just annoying. It’s expensive. A study by IDC put the cost of searching for misplaced documents at around $20 per document in lost productivity. For a small business generating hundreds of documents a month, that adds up fast.</p>

<p>The commercial document management market is full of solutions — but they come with per-user pricing, storage limits, and the quiet assumption that you’re comfortable handing your most sensitive business documents to a third-party cloud. Paperless-ngx is the open-source alternative. It’s a self-hosted document management system that automatically ingests documents, runs optical character recognition (OCR) to make them searchable, assigns tags and categories, and stores everything in a clean, queryable archive. No per-user fees. No storage limits beyond your own server’s disk. No vendor seeing your contracts.</p>

<p>In this guide, we’ll cover what Paperless-ngx does, how it compares to commercial alternatives, and how to deploy it with Docker in an afternoon.</p>

<h2 id="what-paperless-ngx-actually-does">What Paperless-ngx Actually Does</h2>

<p>Paperless-ngx is a document management system built around a simple idea: documents should be searchable, organized, and stored on infrastructure you control. It was forked from the original Paperless project (which went dormant) and has become the de facto open-source standard for small-business and personal document management, with an active community and regular releases.</p>

<p>Here’s what it does out of the box:</p>

<h3 id="1-document-ingestion">1. Document Ingestion</h3>

<p>Paperless-ngx accepts documents from multiple sources:</p>

<ul>
  <li><strong>Consume folder:</strong> Drop a PDF or image into a watched directory, and Paperless picks it up automatically. This is how most scanner integrations work — your multifunction printer scans to a network share, Paperless sees the file, and the rest is automatic.</li>
  <li><strong>Email integration:</strong> Point Paperless at an email account (via IMAP), and it will monitor incoming messages, download attachments, and ingest them. Set rules so only messages from specific senders or with specific subjects are processed. This is perfect for vendor invoices that arrive as email attachments.</li>
  <li><strong>Web upload:</strong> Drag and drop files directly through the Paperless web interface. Useful for one-off documents or when you’re cleaning out a filing cabinet.</li>
  <li><strong>API:</strong> For the technically inclined, Paperless has a REST API. You can push documents programmatically from other systems — an n8n workflow, a script, or another application.</li>
</ul>

<p>Supported formats include PDF, PNG, JPEG, TIFF, and even HEIC (Apple’s image format). If it’s a document, Paperless can probably handle it.</p>

<h3 id="2-ocr-and-full-text-search">2. OCR and Full-Text Search</h3>

<p>This is where Paperless earns its keep. Every document that enters the system runs through Tesseract OCR — the same open-source OCR engine we’ve recommended in our <a href="/blog/2026/09/05/ai-automation-for-accounting-invoice-processing-and-reconciliation/">accounting automation</a> and <a href="/blog/2026/08/18/ai-automation-for-law-firms-document-review-and-intake/">legal document</a> posts. Tesseract extracts the text from scanned images and PDFs, making everything searchable.</p>

<p>That means you can search for “Acme Corporation” and find every document — invoices, contracts, correspondence — that mentions that name, regardless of whether it was a born-digital PDF or a blurry scan of a faxed purchase order. The OCR supports over 100 languages, and you can configure multiple languages simultaneously if your business deals with multilingual documents.</p>

<p>The search is fast. Paperless uses Whoosh (a Python search library) for full-text indexing, and searches return results in milliseconds across thousands of documents.</p>

<h3 id="3-automatic-classification">3. Automatic Classification</h3>

<p>Here’s the feature that surprises people: Paperless can automatically tag, categorize, and assign correspondents to documents based on their content. You define rules — called “matching algorithms” — and Paperless applies them to every new document.</p>

<p>For example:</p>

<ul>
  <li>Any document containing the words “invoice” and “Acme Corp” gets the tag “Invoice,” the correspondent “Acme Corporation,” and the document type “Invoice.”</li>
  <li>Anything from the email address <code class="language-plaintext highlighter-rouge">tax@irs.gov</code> gets the tag “Tax” and is filed under the “Government” document type.</li>
  <li>Documents mentioning “certificate of insurance” are automatically tagged “Insurance” and assigned the storage path “Insurance / 2026.”</li>
</ul>

<p>Three matching algorithms are available:</p>

<table>
  <thead>
    <tr>
      <th>Algorithm</th>
      <th>How It Works</th>
      <th>Best For</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Any Word</strong></td>
      <td>Matches if any of the specified keywords appear</td>
      <td>Broad categories (e.g., “invoice,” “receipt”)</td>
    </tr>
    <tr>
      <td><strong>All Words</strong></td>
      <td>Matches only if all specified keywords appear</td>
      <td>Specific document types (e.g., “certificate” AND “insurance”)</td>
    </tr>
    <tr>
      <td><strong>Exact Match</strong></td>
      <td>Matches an exact string</td>
      <td>Document IDs, specific phrases</td>
    </tr>
  </tbody>
</table>

<p>You can combine tags, correspondents, document types, and storage paths in a single matching rule. Once you’ve tuned your rules over the first few weeks, most documents are filed correctly without any human intervention.</p>

<h3 id="4-document-organization">4. Document Organization</h3>

<p>Paperless organizes documents through four main constructs:</p>

<ul>
  <li><strong>Correspondents:</strong> Who sent or is associated with the document (e.g., “Acme Corporation,” “City Water Department,” “Jane Doe”).</li>
  <li><strong>Document Types:</strong> What kind of document it is (e.g., “Invoice,” “Contract,” “Receipt,” “Tax Form”).</li>
  <li><strong>Tags:</strong> Flexible labels for anything else (e.g., “Urgent,” “Q4 2026,” “Pending Signature”).</li>
  <li><strong>Storage Paths:</strong> Where the document’s original file lives on disk (e.g., “Contracts/2026/” or “Tax/2026/”).</li>
</ul>

<p>Every document can have multiple tags, one correspondent, one document type, and one storage path. The combination gives you powerful filtering: “Show me all invoices from Acme Corporation tagged ‘Q4 2026’” is a two-click query.</p>

<h3 id="5-retention-policies">5. Retention Policies</h3>

<p>For businesses that need to retain documents for specific periods (tax records for seven years, employment records for varying durations depending on jurisdiction), Paperless supports retention policies. You can set rules like “delete receipts after 3 years” or “archive tax forms after 7 years.” The policies apply based on document type and tags, so you can be as specific as you need.</p>

<p>This matters more than people realize. Holding onto documents longer than necessary creates clutter and, in some cases, legal risk (keeping records you were supposed to destroy). Holding them for less time than required creates compliance risk. Paperless lets you set it once and forget it.</p>

<h2 id="paperless-ngx-vs-commercial-document-management">Paperless-ngx vs. Commercial Document Management</h2>

<p>Let’s be honest about the comparison. Commercial document management systems — things like DocuWare, M-Files, or even the document features baked into SharePoint — have more features. They have workflow engines for approval processes, tighter integration with enterprise identity providers, and dedicated sales teams.</p>

<p>But for a small or medium business, those features often come at a cost that’s hard to justify. Here’s how the comparison actually shakes out:</p>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Paperless-ngx</th>
      <th>Commercial DMS (typical)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Cost</strong></td>
      <td>Free (self-hosted)</td>
      <td>$15–$50/user/month + storage fees</td>
    </tr>
    <tr>
      <td><strong>Storage limits</strong></td>
      <td>Your server’s disk</td>
      <td>Typically tiered, extra cost</td>
    </tr>
    <tr>
      <td><strong>OCR</strong></td>
      <td>Tesseract (unlimited)</td>
      <td>Often per-page pricing above a quota</td>
    </tr>
    <tr>
      <td><strong>Data location</strong></td>
      <td>Your server</td>
      <td>Vendor’s cloud</td>
    </tr>
    <tr>
      <td><strong>User accounts</strong></td>
      <td>Unlimited</td>
      <td>Per-seat licensing</td>
    </tr>
    <tr>
      <td><strong>Search</strong></td>
      <td>Full-text, all documents</td>
      <td>Full-text (usually)</td>
    </tr>
    <tr>
      <td><strong>Auto-classification</strong></td>
      <td>Keyword matching</td>
      <td>Often AI/ML-based (more accurate, more complex)</td>
    </tr>
    <tr>
      <td><strong>Workflow/approvals</strong></td>
      <td>Not built-in (use n8n)</td>
      <td>Native</td>
    </tr>
    <tr>
      <td><strong>Mobile access</strong></td>
      <td>Responsive web app</td>
      <td>Usually native apps</td>
    </tr>
    <tr>
      <td><strong>Setup time</strong></td>
      <td>1–2 hours</td>
      <td>Days to weeks (vendor onboarding)</td>
    </tr>
  </tbody>
</table>

<p>The tradeoff is clear: Paperless-ngx gives you 80% of what most small businesses need from a document management system at 0% of the licensing cost. What you give up is workflow automation (which you can fill with n8n) and the polish of a commercial product. For a 5-to-50-person business that wants to stop losing documents and start searching their archive, that’s a trade worth making.</p>

<h2 id="deploying-paperless-ngx-with-docker">Deploying Paperless-ngx with Docker</h2>

<p>Paperless-ngx is designed to be self-hosted, and the recommended deployment method is Docker Compose. You’ll need a server (a VPS with 2 vCPU and 4GB RAM is plenty for a small business), Docker, and Docker Compose installed.</p>

<h3 id="step-1-create-the-project-directory">Step 1: Create the Project Directory</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> /opt/paperless-ngx <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/paperless-ngx
</code></pre></div></div>

<h3 id="step-2-create-the-docker-compose-file">Step 2: Create the Docker Compose File</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">broker</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">docker.io/library/redis:7</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">redisdata:/data</span>

  <span class="na">db</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">docker.io/library/postgres:16</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">pgdata:/var/lib/postgresql/data</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">paperless</span>
      <span class="na">POSTGRES_USER</span><span class="pi">:</span> <span class="s">paperless</span>
      <span class="na">POSTGRES_PASSWORD</span><span class="pi">:</span> <span class="s">${PAPERLESS_DB_PASSWORD}</span>

  <span class="na">paperless</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/paperless-ngx/paperless-ngx:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">db</span>
      <span class="pi">-</span> <span class="s">broker</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">8000:8000"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">data:/usr/src/paperless/data</span>
      <span class="pi">-</span> <span class="s">media:/usr/src/paperless/media</span>
      <span class="pi">-</span> <span class="s">export:/usr/src/paperless/export</span>
      <span class="pi">-</span> <span class="s">consume:/usr/src/paperless/consume</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PAPERLESS_REDIS</span><span class="pi">:</span> <span class="s">redis://broker:6379</span>
      <span class="na">PAPERLESS_DBHOST</span><span class="pi">:</span> <span class="s">db</span>
      <span class="na">PAPERLESS_DBNAME</span><span class="pi">:</span> <span class="s">paperless</span>
      <span class="na">PAPERLESS_DBUSER</span><span class="pi">:</span> <span class="s">paperless</span>
      <span class="na">PAPERLESS_DBPASS</span><span class="pi">:</span> <span class="s">${PAPERLESS_DB_PASSWORD}</span>
      <span class="na">PAPERLESS_SECRET_KEY</span><span class="pi">:</span> <span class="s">${PAPERLESS_SECRET_KEY}</span>
      <span class="na">PAPERLESS_URL</span><span class="pi">:</span> <span class="s">https://docs.yourbusiness.com</span>
      <span class="na">PAPERLESS_OCR_LANGUAGE</span><span class="pi">:</span> <span class="s">eng</span>
      <span class="na">PAPERLESS_CONSUMER_POLLING</span><span class="pi">:</span> <span class="m">60</span>
      <span class="na">PAPERLESS_CONSUMER_RECURSIVE</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">PAPERLESS_TIME_ZONE</span><span class="pi">:</span> <span class="s">America/New_York</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">data</span><span class="pi">:</span>
  <span class="na">media</span><span class="pi">:</span>
  <span class="na">pgdata</span><span class="pi">:</span>
  <span class="na">redisdata</span><span class="pi">:</span>
  <span class="na">export</span><span class="pi">:</span>
  <span class="na">consume</span><span class="pi">:</span>
</code></pre></div></div>

<h3 id="step-3-create-the-environment-file">Step 3: Create the Environment File</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># .env file — keep this secret, don't commit it</span>
<span class="nv">PAPERLESS_DB_PASSWORD</span><span class="o">=</span>change_this_to_a_strong_password
<span class="nv">PAPERLESS_SECRET_KEY</span><span class="o">=</span>change_this_to_a_random_50_char_string
</code></pre></div></div>

<p>Generate a strong secret key:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>openssl rand <span class="nt">-hex</span> 24
</code></pre></div></div>

<h3 id="step-4-start-the-services">Step 4: Start the Services</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<h3 id="step-5-create-your-admin-user">Step 5: Create Your Admin User</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose run <span class="nt">--rm</span> paperless manage createsuperuser
</code></pre></div></div>

<p>Follow the prompts to set your admin username and password.</p>

<h3 id="step-6-set-up-https-with-a-reverse-proxy">Step 6: Set Up HTTPS with a Reverse Proxy</h3>

<p>Paperless-ngx itself runs on port 8000 over HTTP. For production, you should put it behind a reverse proxy with TLS. Caddy is the simplest option — it handles HTTPS certificates automatically:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Add to your Caddyfile or docker-compose</span>
<span class="s">docs.yourbusiness.com {</span>
    <span class="s">reverse_proxy paperless:8000</span>
<span class="err">}</span>
</code></pre></div></div>

<p>Caddy will automatically obtain and renew a Let’s Encrypt certificate. No manual cert management required.</p>

<h3 id="step-7-start-scanning">Step 7: Start Scanning</h3>

<p>Drop a PDF or image into the <code class="language-plaintext highlighter-rouge">consume</code> folder (or configure your scanner to save there), and within 60 seconds Paperless will pick it up, OCR it, and add it to your archive. Log into the web interface to review, tag, and organize.</p>

<h2 id="practical-workflows-for-small-business">Practical Workflows for Small Business</h2>

<p>Here’s how businesses actually use Paperless-ngx day-to-day:</p>

<h3 id="invoice-processing">Invoice Processing</h3>

<p>Configure Paperless to monitor the email account where vendor invoices arrive. Every PDF attachment gets ingested, OCR’d, and tagged as “Invoice” based on your matching rules. Your bookkeeper logs in once a week, reviews the tagged invoices, and exports them — or better yet, use n8n to push the extracted data into your accounting system.</p>

<h3 id="contract-management">Contract Management</h3>

<p>Every signed contract gets uploaded (via scanner, email, or web interface). Tag by client name, contract type, and renewal date. When you need to review a contract before renewal, search by client name and filter by “Contract” — it’s a five-second lookup instead of a forty-minute dig through filing cabinets.</p>

<h3 id="tax-document-organization">Tax Document Organization</h3>

<p>Create a “Tax 2026” tag. Throughout the year, tag every tax-relevant document — 1099s, receipts for deductible expenses, property tax bills, charitable donation acknowledgments. When tax season arrives, filter by the tag and export everything for your accountant. Set a retention policy to archive the tag after seven years.</p>

<h3 id="employee-records">Employee Records</h3>

<p>Onboarding paperwork, performance reviews, training certificates — all tagged by employee name and document type. Set retention policies based on your legal requirements. When an employee leaves, you know exactly where everything is and when it can be destroyed.</p>

<h2 id="integrating-paperless-ngx-with-your-automation-stack">Integrating Paperless-ngx with Your Automation Stack</h2>

<p>Paperless-ngx has a REST API, which means it plays well with the automation tools we’ve covered elsewhere on this blog. A few integration patterns:</p>

<p><strong>Paperless + n8n + Ollama:</strong> When a new document is ingested, trigger an n8n workflow that sends the OCR’d text to a local LLM (via Ollama) for classification. The LLM can extract structured data — vendor name, invoice number, total amount, due date — and push it to your accounting system. This is a more sophisticated version of the keyword matching that Paperless does natively.</p>

<p><strong>Paperless + n8n + notifications:</strong> Set up a webhook so that when a document matching certain criteria is added (e.g., a document tagged “Urgent”), n8n sends a notification to your team chat.</p>

<p><strong>Paperless + Metabase:</strong> Export Paperless’s document metadata to a database and connect Metabase to visualize it — how many invoices per month, average processing time, document volume by correspondent. This gives you analytics on your document workflow.</p>

<h2 id="hardware-requirements-and-performance">Hardware Requirements and Performance</h2>

<p>Paperless-ngx is not a heavy application. For a small business with a few thousand documents:</p>

<ul>
  <li><strong>CPU:</strong> 2 vCPU is sufficient. OCR is the most CPU-intensive task, and it’s parallelized.</li>
  <li><strong>RAM:</strong> 4GB minimum. 8GB if you’re processing large batches of scanned documents.</li>
  <li><strong>Storage:</strong> Plan for roughly 1–2MB per document (after compression). 10,000 documents = ~15GB. A 100GB disk gives you years of headroom.</li>
  <li><strong>Database:</strong> PostgreSQL is recommended for any production deployment. SQLite works for testing but isn’t recommended for multi-user use.</li>
</ul>

<p>OCR processing time depends on document length and CPU. A typical 5-page scanned invoice takes 10–20 seconds on a 2 vCPU server. Paperless processes documents asynchronously, so this doesn’t block the web interface.</p>

<h2 id="migration-getting-your-existing-documents-in">Migration: Getting Your Existing Documents In</h2>

<p>If you’re migrating from an existing system (filing cabinet, network drive, Google Drive export), the process is straightforward but takes time:</p>

<ol>
  <li><strong>Scan paper documents.</strong> A sheet-fed scanner (anything from a $200 desktop scanner to a multifunction printer) can batch-scan years of paper. Save scans as PDF to the consume folder.</li>
  <li><strong>Batch import digital files.</strong> Paperless includes a <code class="language-plaintext highlighter-rouge">document_importer</code> management command that can ingest a directory of existing files, preserving creation dates from file metadata.</li>
  <li><strong>Clean up as you go.</strong> The first pass will have classification errors. Spend a week reviewing and correcting tags — your matching rules will improve as a result.</li>
</ol>

<p>A realistic timeline: a small business with a filing cabinet and a few hundred digital documents can be fully migrated in a weekend. A larger archive (10,000+ documents) is a multi-week project, but you can do it incrementally — start using Paperless for new documents immediately, and backfill the archive over time.</p>

<h2 id="backups-non-negotiable">Backups: Non-Negotiable</h2>

<p>Paperless-ngx stores your documents on your server. That’s the whole point — your data, your control. But it also means you’re responsible for backups. A single disk failure without a backup means losing your entire document archive.</p>

<p>At minimum:</p>

<ol>
  <li><strong>Back up the media volume</strong> (where document files are stored) daily. This is the irreplaceable data.</li>
  <li><strong>Back up the database</strong> (PostgreSQL) daily. This stores all metadata, tags, and search indices.</li>
  <li><strong>Store backups off-site.</strong> Use <code class="language-plaintext highlighter-rouge">restic</code> or <code class="language-plaintext highlighter-rouge">borg</code> to push encrypted backups to a separate location — a different VPS, an S3-compatible storage service like Backblaze B2 or MinIO.</li>
  <li><strong>Test your restores.</strong> A backup you’ve never restored is a hope, not a backup.</li>
</ol>

<p>Paperless also has a built-in export feature (<code class="language-plaintext highlighter-rouge">document_exporter</code>) that creates a complete export of all documents and metadata. Schedule it weekly via cron and include the export in your backup routine.</p>

<h2 id="common-pitfalls">Common Pitfalls</h2>

<p><strong>Underestimating OCR quality.</strong> Tesseract is good but not perfect. Handwritten notes, low-resolution scans, and documents with unusual fonts will produce OCR errors. Invest in a decent scanner — 300 DPI is the minimum for reliable OCR, 600 DPI is better for small text.</p>

<p><strong>Over-tagging.</strong> It’s tempting to create a tag for every possible categorization. Resist. Start with 5–10 tags and 3–5 document types. You can always add more. Too many tags makes the system harder to use, not easier.</p>

<p><strong>Ignoring the consume folder permissions.</strong> If your scanner saves files as one user but Paperless runs as another, file permission issues will silently prevent ingestion. Make sure the consume folder is writable by the Paperless container user.</p>

<p><strong>No retention policy.</strong> Without one, your archive grows indefinitely. Set retention policies early, even if they’re conservative (e.g., “archive after 10 years”). You can tighten them later.</p>

<h2 id="the-bottom-line">The Bottom Line</h2>

<p>Paperless-ngx solves a problem that every business has and most businesses handle badly. Document chaos isn’t a technology problem — it’s a process problem that technology can fix. The question isn’t whether you need document management. You do. The question is whether you’ll pay a recurring subscription for a commercial product that holds your most sensitive documents, or whether you’ll spend an afternoon setting up an open-source system that gives you the same core capabilities on your own terms.</p>

<p>For most small and medium businesses, the answer is clear. Paperless-ngx gives you searchable, organized, automatically-classified document storage with no per-user fees, no storage limits, and no vendor looking at your files. Pair it with n8n for workflow automation and Ollama for intelligent classification, and you have a document management stack that rivals commercial systems at a fraction of the cost.</p>

<p>If you’d like help deploying Paperless-ngx or building a document automation workflow for your business, <a href="/#contact">reach out through our contact form</a>. We specialize in open-source automation for small and medium businesses — practical solutions, no vendor lock-in, your data stays yours.</p>

<hr />

<p><em>Paperless-ngx is open-source software released under the GPL-3.0 license. It’s community-maintained and free to use. You can find the project documentation and source code at <a href="https://docs.paperless-ngx.com">docs.paperless-ngx.com</a>.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="paperless-ngx" /><category term="document-management" /><category term="ocr" /><category term="self-hosting" /><category term="docker" /><category term="open-source" /><category term="paperless" /><summary type="html"><![CDATA[Every business drowns in paper and PDFs. Paperless-ngx is the open-source document management system that automatically scans, OCRs, tags, and files everything — self-hosted, no subscriptions, your data stays yours.]]></summary></entry><entry><title type="html">Self-Hosting Metabase: Open-Source Business Analytics Without the SaaS Tax</title><link href="https://www.ardotconsulting.com/blog/2026/09/30/self-hosting-metabase-open-source-business-analytics-without-saas/" rel="alternate" type="text/html" title="Self-Hosting Metabase: Open-Source Business Analytics Without the SaaS Tax" /><published>2026-09-30T00:00:00-07:00</published><updated>2026-09-30T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/09/30/self-hosting-metabase-open-source-business-analytics-without-saas</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/09/30/self-hosting-metabase-open-source-business-analytics-without-saas/"><![CDATA[<p>Every growing business eventually hits the same wall: you have data spread across a dozen tools — your CRM, your invoicing system, your web analytics, your inventory manager — but no single place to see it all. The obvious answer is a business intelligence (BI) tool. The obvious problem is the price tag.</p>

<p>Tableau starts at $75 per user per month. Looker requires a conversation with sales before you even see a number. Power BI’s licensing tiers are a spreadsheet unto themselves. For a 10-person team that needs three dashboards, you’re looking at $2,000+ per year before anyone has built a single chart.</p>

<p>Metabase is the open-source alternative. It gives you visual dashboards, a no-code query builder, and native SQL access — all self-hosted, all yours, with zero per-user licensing. In this guide, we’ll walk through what Metabase does well, how to deploy it with Docker, and how to connect it to your existing data sources.</p>

<h2 id="what-metabase-actually-does">What Metabase Actually Does</h2>

<p>Metabase is a business intelligence tool. You connect it to a database (PostgreSQL, MySQL, MongoDB, SQLite, and others), and it gives you two ways to explore that data:</p>

<ol>
  <li>
    <p><strong>A visual query builder</strong> — filter, group, and aggregate data without writing SQL. You pick a table, choose columns, apply filters, and Metabase generates the chart. This is what non-technical team members use day-to-day.</p>
  </li>
  <li>
    <p><strong>Native SQL editor</strong> — write your own queries, save them as reusable models, and build dashboards from the results. This is what your technical folks use for complex analysis.</p>
  </li>
</ol>

<p>The key feature that separates Metabase from a simple charting library is <strong>questions and dashboards</strong>. A “question” is any saved query — visual or SQL. A “dashboard” is a collection of questions arranged on a grid with filters that affect all questions at once. You build once, and your team interacts without touching the underlying query.</p>

<h3 id="what-it-does-not-do">What It Does Not Do</h3>

<p>Metabase is not an ETL tool. It reads from your database; it doesn’t transform or move data between systems. If your data lives in five different databases, you’ll need to consolidate it first (or connect Metabase to each one separately). For ETL, look at tools like n8n or Directus — we covered building data pipelines in a <a href="/blog/2026/09/28/building-data-pipelines-connect-your-business-systems-without-code/">previous post</a>.</p>

<p>Metabase also doesn’t do real-time streaming analytics. It queries your database on demand or on a refresh schedule (every hour, daily, etc.). For most small-to-medium businesses, that’s perfectly fine.</p>

<h2 id="metabase-vs-other-bi-tools">Metabase vs Other BI Tools</h2>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Metabase (OSS)</th>
      <th>Tableau</th>
      <th>Power BI</th>
      <th>Grafana</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>License</td>
      <td>AGPL (free, self-hosted)</td>
      <td>Commercial</td>
      <td>Commercial</td>
      <td>AGPL (free, self-hosted)</td>
    </tr>
    <tr>
      <td>Per-user cost</td>
      <td>$0</td>
      <td>$75+/user/mo</td>
      <td>$10-20/user/mo</td>
      <td>$0</td>
    </tr>
    <tr>
      <td>No-code query builder</td>
      <td>Yes</td>
      <td>Yes</td>
      <td>Yes</td>
      <td>Limited</td>
    </tr>
    <tr>
      <td>Native SQL</td>
      <td>Yes</td>
      <td>Yes</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Self-hosted</td>
      <td>Yes</td>
      <td>No</td>
      <td>No (Power BI Report Server limited)</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Best for</td>
      <td>Business analytics</td>
      <td>Enterprise BI</td>
      <td>Microsoft shops</td>
      <td>Infrastructure/ops monitoring</td>
    </tr>
    <tr>
      <td>Setup time</td>
      <td>~15 min with Docker</td>
      <td>Days (procurement + setup)</td>
      <td>Hours</td>
      <td>~15 min with Docker</td>
    </tr>
  </tbody>
</table>

<p>The Grafana comparison is worth clarifying. Grafana is excellent, but it’s designed for time-series monitoring — server metrics, application performance, IoT sensors. Metabase is designed for business data — sales, inventory, customer behavior. They overlap in capability but serve different primary use cases. Many businesses run both.</p>

<h2 id="deploying-metabase-with-docker">Deploying Metabase with Docker</h2>

<p>The fastest way to get Metabase running is Docker. You’ll need Docker and Docker Compose installed on a server (a $10/month VPS is plenty for a small team). Here’s a complete <code class="language-plaintext highlighter-rouge">docker-compose.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">metabase</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">metabase/metabase:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">metabase</span>
    <span class="na">hostname</span><span class="pi">:</span> <span class="s">metabase</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3000:3000"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">MB_DB_TYPE=postgres</span>
      <span class="pi">-</span> <span class="s">MB_DB_DBNAME=metabase</span>
      <span class="pi">-</span> <span class="s">MB_DB_PORT=5432</span>
      <span class="pi">-</span> <span class="s">MB_DB_USER=metabase</span>
      <span class="pi">-</span> <span class="s">MB_DB_PASS=change_this_password</span>
      <span class="pi">-</span> <span class="s">MB_DB_HOST=metabase-db</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">metabase-db</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">metabase-db</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:15-alpine</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">metabase-db</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">POSTGRES_USER=metabase</span>
      <span class="pi">-</span> <span class="s">POSTGRES_PASSWORD=change_this_password</span>
      <span class="pi">-</span> <span class="s">POSTGRES_DB=metabase</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">metabase-db-data:/var/lib/postgresql/data</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">metabase-db-data</span><span class="pi">:</span>
</code></pre></div></div>

<p>Save this as <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> and run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Metabase uses its own PostgreSQL database to store dashboards, saved questions, and user accounts — that’s the <code class="language-plaintext highlighter-rouge">metabase-db</code> service. The app itself runs on port 3000. Open <code class="language-plaintext highlighter-rouge">http://your-server-ip:3000</code> in a browser and you’ll see the setup wizard.</p>

<h3 id="securing-the-deployment">Securing the Deployment</h3>

<p>Running Metabase on port 3000 without TLS is fine for testing, but for production you should put it behind a reverse proxy with HTTPS. Here’s a minimal Caddy configuration:</p>

<pre><code class="language-caddyfile">analytics.yourcompany.com {
    reverse_proxy localhost:3000
}
</code></pre>

<p>Caddy automatically provisions and renews Let’s Encrypt certificates. Install Caddy, drop this config in <code class="language-plaintext highlighter-rouge">/etc/caddy/Caddyfile</code>, and restart. Your Metabase instance is now accessible over HTTPS at <code class="language-plaintext highlighter-rouge">analytics.yourcompany.com</code> — no certificate management required.</p>

<h3 id="authentication-and-access-control">Authentication and Access Control</h3>

<p>Metabase has built-in user management. During setup, you create an admin account. From the admin settings, you can:</p>

<ul>
  <li><strong>Create user accounts</strong> for each team member with email/password login</li>
  <li><strong>Define groups</strong> (e.g., “Sales”, “Engineering”, “Leadership”)</li>
  <li><strong>Set permissions</strong> per database, per table, or per saved question — control who can view, who can edit, and who sees raw data vs aggregated results</li>
  <li><strong>Enable SSO</strong> via LDAP or SAML if you have an identity provider</li>
</ul>

<p>For a small team, the default email/password auth is fine. For larger organizations, connect Metabase to your existing SSO provider so access is managed centrally.</p>

<h2 id="connecting-metabase-to-your-data">Connecting Metabase to Your Data</h2>

<p>Once Metabase is running, the real work begins: connecting it to your data sources. Here are the most common scenarios for small businesses:</p>

<h3 id="scenario-1-you-already-have-a-postgresql-database">Scenario 1: You Already Have a PostgreSQL Database</h3>

<p>If your application (Odoo, Directus, a custom app) already uses PostgreSQL, Metabase can connect directly. In the admin settings:</p>

<ol>
  <li>Click <strong>Add Database</strong></li>
  <li>Select <strong>PostgreSQL</strong></li>
  <li>Enter the host, port, database name, username, and password</li>
  <li>Metabase scans the schema and makes all tables available</li>
</ol>

<p>That’s it. Your team can immediately start building charts from your production data.</p>

<h3 id="scenario-2-your-data-is-in-a-csv-or-spreadsheet">Scenario 2: Your Data Is in a CSV or Spreadsheet</h3>

<p>Metabase doesn’t connect directly to CSV files, but you can load them into a SQLite database in seconds:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install sqlite3 if needed</span>
<span class="nb">sudo </span>apt <span class="nb">install </span>sqlite3

<span class="c"># Create a database from a CSV</span>
sqlite3 mydata.db
.mode csv
.import sales_data.csv sales
.import customers.csv customers
.exit
</code></pre></div></div>

<p>Then run a temporary Metabase container with the SQLite file mounted:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="nt">-p</span> 3000:3000 <span class="se">\</span>
  <span class="nt">-v</span> /path/to/mydata.db:/data/mydata.db <span class="se">\</span>
  metabase/metabase:latest
</code></pre></div></div>

<p>In the Metabase admin settings, add a new database of type “SQLite” and point it to <code class="language-plaintext highlighter-rouge">/data/mydata.db</code>. Your spreadsheet data is now queryable with the full power of Metabase’s visualization engine.</p>

<h3 id="scenario-3-multiple-data-sources">Scenario 3: Multiple Data Sources</h3>

<p>If your CRM is in PostgreSQL and your web analytics are in a separate database, connect Metabase to both. You’ll have two databases listed in Metabase, and you can build dashboards that pull from either — though cross-database queries aren’t supported natively. For unified reporting, consolidate your data into a single database using an ETL pipeline (n8n is excellent for this).</p>

<h2 id="building-your-first-dashboard">Building Your First Dashboard</h2>

<p>Let’s walk through a practical example. Say you want a sales overview dashboard with three panels: revenue by month, top 10 customers, and order count by status.</p>

<h3 id="step-1-connect-your-database">Step 1: Connect Your Database</h3>

<p>In the admin panel, add your PostgreSQL database. Metabase auto-detects tables and columns.</p>

<h3 id="step-2-create-a-revenue-by-month-question">Step 2: Create a “Revenue by Month” Question</h3>

<ol>
  <li>Click <strong>+ New</strong> → <strong>Question</strong></li>
  <li>Select your database and the <code class="language-plaintext highlighter-rouge">orders</code> table</li>
  <li>Choose <strong>Sum of</strong> <code class="language-plaintext highlighter-rouge">total_amount</code></li>
  <li>Group by <code class="language-plaintext highlighter-rouge">created_at</code> → <strong>Month</strong></li>
  <li>Pick a bar chart visualization</li>
  <li>Save the question as “Revenue by Month”</li>
</ol>

<h3 id="step-3-create-a-top-10-customers-question">Step 3: Create a “Top 10 Customers” Question</h3>

<ol>
  <li>Click <strong>+ New</strong> → <strong>Native Query</strong></li>
  <li>Write:
    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="k">c</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="k">SUM</span><span class="p">(</span><span class="n">o</span><span class="p">.</span><span class="n">total_amount</span><span class="p">)</span> <span class="k">AS</span> <span class="n">total_spent</span>
<span class="k">FROM</span> <span class="n">orders</span> <span class="n">o</span>
<span class="k">JOIN</span> <span class="n">customers</span> <span class="k">c</span> <span class="k">ON</span> <span class="n">o</span><span class="p">.</span><span class="n">customer_id</span> <span class="o">=</span> <span class="k">c</span><span class="p">.</span><span class="n">id</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="k">c</span><span class="p">.</span><span class="n">name</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total_spent</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="mi">10</span>
</code></pre></div>    </div>
  </li>
  <li>Choose a horizontal bar chart</li>
  <li>Save as “Top 10 Customers”</li>
</ol>

<h3 id="step-4-create-an-orders-by-status-question">Step 4: Create an “Orders by Status” Question</h3>

<ol>
  <li>Visual query builder → <code class="language-plaintext highlighter-rouge">orders</code> table</li>
  <li>Count of rows</li>
  <li>Group by <code class="language-plaintext highlighter-rouge">status</code></li>
  <li>Choose a pie or donut chart</li>
  <li>Save as “Orders by Status”</li>
</ol>

<h3 id="step-5-assemble-the-dashboard">Step 5: Assemble the Dashboard</h3>

<ol>
  <li>Click <strong>+ New</strong> → <strong>Dashboard</strong></li>
  <li>Name it “Sales Overview”</li>
  <li>Add your three saved questions</li>
  <li>Add a date filter that affects the Revenue question</li>
  <li>Arrange the panels with drag-and-drop</li>
</ol>

<p>The entire process takes about 15 minutes for a straightforward dashboard. Once saved, any team member with access can view it, and the data refreshes on every page load (or on the schedule you configure).</p>

<h2 id="automating-metabase-with-n8n">Automating Metabase with n8n</h2>

<p>Metabase has a REST API that lets you programmatically query saved questions, trigger dashboard refreshes, and export results. Combined with n8n, you can build automated workflows like:</p>

<ul>
  <li><strong>Daily email report</strong> — every morning, n8n calls the Metabase API to fetch yesterday’s revenue numbers and emails a summary to the team</li>
  <li><strong>Slack/Mattermost alert</strong> — when a saved question returns results below a threshold, n8n posts an alert to a channel</li>
  <li><strong>Scheduled export</strong> — every Friday, n8n pulls a dashboard as CSV and uploads it to a shared drive</li>
</ul>

<p>Here’s an example n8n workflow that sends a weekly Metabase summary via Mattermost:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"trigger"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Every Monday at 8:00 AM"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"steps"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"HTTP Request"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GET"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://metabase:3000/api/card/42/query"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"headers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"X-Metabase-Session"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Set"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"fields"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"revenue"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
        </span><span class="nl">"orders"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Mattermost"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"📊 Weekly Summary: Revenue $ from  orders."</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This pattern — Metabase for visualization, n8n for orchestration — gives you a BI platform that rivals commercial tools costing thousands per year, entirely on infrastructure you control.</p>

<h2 id="realistic-costs">Realistic Costs</h2>

<p>Self-hosting isn’t free — you pay for the server. Here’s a realistic cost breakdown for a 10-person team:</p>

<table>
  <thead>
    <tr>
      <th>Item</th>
      <th>Monthly Cost</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VPS (2 vCPU, 4GB RAM)</td>
      <td>$10-20</td>
      <td>Hetzner, DigitalOcean, or similar</td>
    </tr>
    <tr>
      <td>Domain (analytics subdomain)</td>
      <td>$1</td>
      <td>If you already own the domain</td>
    </tr>
    <tr>
      <td>TLS certificate</td>
      <td>$0</td>
      <td>Let’s Encrypt via Caddy</td>
    </tr>
    <tr>
      <td>Metabase license</td>
      <td>$0</td>
      <td>AGPL open source</td>
    </tr>
    <tr>
      <td>Per-user licensing</td>
      <td>$0</td>
      <td>Unlimited users</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>$11-21/mo</strong></td>
      <td>For the entire team</td>
    </tr>
  </tbody>
</table>

<p>Compare that to Tableau Creator at $75/user/month — for 10 users, that’s $750/month, or $9,000/year. The self-hosted Metabase setup costs under $250/year for the same team.</p>

<p>The trade-off is maintenance. You’re responsible for backups, updates, and security patches. This is real work, but it’s a few hours per quarter, not a full-time job. If that still feels like too much, Metabase also offers a cloud-hosted version starting at $85/month for 5 users — still significantly cheaper than Tableau, and with a clear upgrade path to self-hosting when you’re ready.</p>

<h2 id="when-to-choose-metabase-and-when-not-to">When to Choose Metabase (and When Not To)</h2>

<p><strong>Choose Metabase if:</strong></p>

<ul>
  <li>You have a database and want visual dashboards without per-user licensing</li>
  <li>Your team needs both no-code and SQL access to data</li>
  <li>You want to self-host for data sovereignty or compliance reasons</li>
  <li>You’re already using PostgreSQL, MySQL, or MongoDB</li>
</ul>

<p><strong>Consider alternatives if:</strong></p>

<ul>
  <li>You need real-time streaming analytics (look at Apache Superset with a columnar database)</li>
  <li>Your team has no one who can manage a Docker deployment (use Metabase Cloud or a managed alternative)</li>
  <li>You need pixel-perfect, branded report exports (Metabase’s exports are functional but not design-grade)</li>
  <li>Your data is primarily in spreadsheets and you don’t want to import it into a database (look at a tool like Grist, which works directly with spreadsheets)</li>
</ul>

<h2 id="getting-started-checklist">Getting Started Checklist</h2>

<ol>
  <li><strong>[ ] Provision a VPS</strong> with at least 2 vCPU and 4GB RAM</li>
  <li><strong>[ ] Install Docker and Docker Compose</strong> on the server</li>
  <li><strong>[ ] Copy the docker-compose.yml</strong> from above and run <code class="language-plaintext highlighter-rouge">docker compose up -d</code></li>
  <li><strong>[ ] Set up Caddy</strong> (or nginx) as a reverse proxy with HTTPS</li>
  <li><strong>[ ] Complete the Metabase setup wizard</strong> — create your admin account</li>
  <li><strong>[ ] Connect your first database</strong> — start with whatever has the most interesting data</li>
  <li><strong>[ ] Build your first dashboard</strong> — pick a question your team asks every week</li>
  <li><strong>[ ] Create user accounts</strong> for your team and assign them to groups</li>
  <li><strong>[ ] Set up a backup</strong> — at minimum, <code class="language-plaintext highlighter-rouge">pg_dump</code> the Metabase database daily</li>
  <li><strong>[ ] Schedule a review</strong> — after 30 days, check what dashboards are actually being used</li>
</ol>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>Business intelligence doesn’t have to mean a five-figure annual contract. Metabase gives you the core capabilities — visual queries, SQL access, shared dashboards, user permissions — in a package you can deploy in an afternoon and run for the cost of a cheap server.</p>

<p>The combination of Metabase for analytics, n8n for automation, and tools like Ollama for AI creates a complete open-source business stack that replaces commercial alternatives at a fraction of the cost. You keep your data, you control your costs, and you’re never at the mercy of a pricing page change.</p>

<p>If you’d like help setting up Metabase or building a custom analytics dashboard for your business, <a href="/#contact">reach out through our contact form</a>. We specialize in open-source automation for small and medium businesses — no vendor lock-in, no surprise bills.</p>

<hr />

<p><em>Want to see Metabase in action with your own data? ARDOT Consulting can help you deploy, configure, and connect your business systems. <a href="/#contact">Contact us</a> to schedule a free consultation.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="metabase" /><category term="analytics" /><category term="business-intelligence" /><category term="self-hosting" /><category term="docker" /><category term="open-source" /><summary type="html"><![CDATA[Replace expensive BI tools with self-hosted Metabase — open-source dashboards and SQL queries that keep your data on your own server.]]></summary></entry><entry><title type="html">Building Data Pipelines: Connect Your Business Systems Without Writing Code</title><link href="https://www.ardotconsulting.com/blog/2026/09/28/building-data-pipelines-connect-your-business-systems-without-code/" rel="alternate" type="text/html" title="Building Data Pipelines: Connect Your Business Systems Without Writing Code" /><published>2026-09-28T00:00:00-07:00</published><updated>2026-09-28T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/09/28/building-data-pipelines-connect-your-business-systems-without-code</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/09/28/building-data-pipelines-connect-your-business-systems-without-code/"><![CDATA[<p>Most small businesses run on a patchwork of disconnected tools. Your CRM holds customer contacts. Your invoicing system holds payment records. Your project management tool tracks deliverables. Your email platform stores communication history. Your website analytics tool tracks visitor behavior. Each tool is decent at its job, but none of them talk to each other.</p>

<p>The result is a daily ritual of manual data movement. You export a CSV from one system, clean it up in a spreadsheet, import it into another, and hope nothing changed in between. Your team spends hours every week acting as human data pipelines — copying, pasting, reformatting, and reconciling information between systems that should have been connected from the start.</p>

<p>This isn’t a technology problem. It’s a plumbing problem. And like most plumbing problems, the fix isn’t glamorous, but it changes everything once it works. This post is about building data pipelines with open source tools that connect your business systems automatically — no code, no per-connector SaaS fees, no data flowing through third-party clouds you don’t control.</p>

<h2 id="what-a-data-pipeline-actually-does">What a Data Pipeline Actually Does</h2>

<p>A data pipeline is a series of automated steps that move data from one place to another, optionally transforming it along the way. That’s it. The term sounds technical, but the concept is simple:</p>

<ol>
  <li><strong>Extract</strong> — Pull data from a source (a CRM, a database, a spreadsheet, an API)</li>
  <li><strong>Transform</strong> — Change the data’s format, filter it, enrich it, or combine it with other data</li>
  <li><strong>Load</strong> — Put the data somewhere useful (another database, a dashboard, a report, a notification)</li>
</ol>

<p>You might see this called ETL (Extract, Transform, Load) in enterprise contexts. The business version is simpler: “when something happens in System A, make sure System B knows about it.”</p>

<p>Here are some concrete examples of what this looks like in practice:</p>

<ul>
  <li><strong>New customer in CRM → invoice created in accounting</strong> — When a lead converts to a customer in your CRM, a draft invoice is automatically created in your accounting system with the correct billing details.</li>
  <li><strong>Order placed on website → inventory updated in ERP</strong> — When an order comes through your e-commerce site, stock levels are decremented in your inventory management system and a reorder alert fires if stock drops below threshold.</li>
  <li><strong>Support ticket closed → metrics updated on dashboard</strong> — When a support ticket is resolved, the resolution time is logged to a metrics database and a weekly performance dashboard updates automatically.</li>
  <li><strong>Form submission → lead scored and routed</strong> — When someone fills out a contact form, their data is enriched with company information, scored against your ideal customer profile, and routed to the right salesperson.</li>
</ul>

<p>Each of these pipelines replaces a manual process. Each one eliminates a class of errors — the wrong customer ID, the missed reorder, the forgotten follow-up. And each one can be built without writing a single line of code.</p>

<h2 id="the-tool-stack">The Tool Stack</h2>

<p>For data pipelines in a small business context, you need three components:</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>What It Does</th>
      <th>Open Source Option</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Orchestrator</strong></td>
      <td>Runs the pipeline on a schedule or trigger, handles retries and error logging</td>
      <td><strong>n8n</strong></td>
    </tr>
    <tr>
      <td><strong>Data Store</strong></td>
      <td>Holds the data at rest between steps, serves as a source or destination</td>
      <td><strong>PostgreSQL</strong> or <strong>Directus</strong></td>
    </tr>
    <tr>
      <td><strong>Transformation</strong></td>
      <td>Cleans, filters, enriches, or reformats data as it moves</td>
      <td><strong>n8n nodes</strong> (for simple) or <strong>Ollama</strong> (for AI-powered enrichment)</td>
    </tr>
  </tbody>
</table>

<p>n8n is the backbone here. We’ve covered it extensively in previous posts, but the short version: it’s a self-hosted workflow automation tool with a visual builder. You drag nodes onto a canvas, connect them, and configure each one. It has built-in connectors for hundreds of tools — CRMs, accounting software, databases, email platforms, messaging apps — and supports custom HTTP requests for anything it doesn’t cover natively.</p>

<p>The key advantage over SaaS alternatives like Zapier is that n8n runs on your own server. There are no per-task fees (Zapier charges $0.01–$0.06 per task on paid plans, which adds up fast for high-volume pipelines). There are no limits on the number of active workflows. And your data flows through infrastructure you control, not through a third-party cloud that could change its pricing, terms, or data handling policies at any time.</p>

<h2 id="a-real-pipeline-crm-to-accounting">A Real Pipeline: CRM to Accounting</h2>

<p>Let’s build a real pipeline step by step. This is one of the most common integration needs: syncing customer data from a CRM to an accounting system so that invoices are created with correct billing information. We’ll use Odoo (self-hosted CRM) as the source and a generic accounting API as the destination, but the pattern works with any pair of systems.</p>

<h3 id="step-1-set-up-the-trigger">Step 1: Set Up the Trigger</h3>

<p>In n8n, create a new workflow. The first node is the trigger — the event that starts the pipeline. For a CRM-to-accounting sync, you want the pipeline to fire whenever a new customer is created:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Trigger: Webhook from Odoo
- Listen for: contact.created event
- Authentication: API key (stored in n8n credentials)
- Output: JSON with customer name, email, address, tax ID
</code></pre></div></div>

<p>Odoo’s webhook system sends a POST request to your n8n instance whenever a new contact is created. n8n receives the payload and passes it to the next node.</p>

<p>If your source system doesn’t support webhooks, you can use a scheduled trigger instead — n8n checks for new records every 15 minutes and processes any that haven’t been synced yet. The tradeoff: webhooks are real-time but require the source system to support them; polling is slower but works with any system that has an API.</p>

<h3 id="step-2-transform-the-data">Step 2: Transform the Data</h3>

<p>The customer record from Odoo probably doesn’t match the format your accounting system expects. Field names differ, address components are structured differently, and some fields need to be calculated. This is the transform step.</p>

<p>In n8n, add a <strong>Set</strong> node (for field mapping) and a <strong>Function</strong> node (for any logic):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Set Node: Map Fields
- company_name → customer_name
- email → billing_email
- street + city + zip → billing_address (combined)
- vat_number → tax_id

Function Node: Validate
- Check that customer_name is not empty
- Check that billing_email is a valid format
- Check that tax_id matches expected pattern
- If any check fails, route to error handling node
</code></pre></div></div>

<p>This is where most pipelines spend their complexity. Data transformation is unglamorous but critical — garbage in, garbage out applies at every stage. The validation step is especially important: if a customer record is missing a required field, you want to catch it here and alert a human, not create a broken invoice downstream.</p>

<h3 id="step-3-check-for-duplicates">Step 3: Check for Duplicates</h3>

<p>Before creating a new record in the accounting system, check whether one already exists. This prevents duplicate customers and invoices — a common problem when pipelines run on schedules and process the same records multiple times:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP Request Node: Search Existing Customer
- Method: GET
- URL: https://accounting.example.com/api/customers?email={billing_email}
- If response contains a customer → skip creation, optionally update
- If response is empty → proceed to creation
</code></pre></div></div>

<h3 id="step-4-load-the-data">Step 4: Load the Data</h3>

<p>Now create the customer record in the accounting system:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP Request Node: Create Customer
- Method: POST
- URL: https://accounting.example.com/api/customers
- Body: {
    "customer_name": "",
    "billing_email": "",
    "billing_address": "",
    "tax_id": ""
  }
- Headers: Authorization: Bearer {api_key}
</code></pre></div></div>

<h3 id="step-5-log-and-notify">Step 5: Log and Notify</h3>

<p>The final step is logging and notification. You want a record of what the pipeline did, and you want to know if something went wrong:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PostgreSQL Node: Log Sync
- INSERT INTO sync_log (source, destination, record_id, status, timestamp)
- VALUES ('odoo', 'accounting', '', 'success', NOW())

IF Node: Check for Errors
- If error → Mattermost notification to #ops channel
- If success → no notification (silent on success)
</code></pre></div></div>

<p>The full pipeline looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Odoo Webhook] → [Map Fields] → [Validate] → [Search Existing] → [Create Customer] → [Log Sync]
                                                                         ↓
                                                                  [Error Handler] → [Mattermost Alert]
</code></pre></div></div>

<p>That’s a complete data pipeline. It runs automatically, retries on failure, logs every action, and alerts a human only when something goes wrong. Once it’s built, you never think about it again — which is the whole point.</p>

<h2 id="when-to-add-ai-to-the-pipeline">When to Add AI to the Pipeline</h2>

<p>Not every pipeline needs AI. The CRM-to-accounting sync above is pure rule-based logic — no AI required. But some transformations benefit from intelligence:</p>

<ul>
  <li><strong>Categorizing free-text data</strong> — A support ticket’s subject line needs to be mapped to a product category. Rules can’t handle the variety; an LLM can.</li>
  <li><strong>Enriching incomplete records</strong> — A customer gives you a company name but no industry code. An LLM can infer the industry from the name and public data.</li>
  <li><strong>Sentiment analysis</strong> — You want to route customer feedback to different teams based on whether it’s positive, negative, or neutral.</li>
  <li><strong>Entity extraction</strong> — You need to pull names, dates, and amounts from unstructured email bodies and turn them into structured fields.</li>
</ul>

<p>For these cases, add an <strong>Ollama</strong> node to the pipeline. Ollama runs a local LLM on your server, processes the data in memory, and returns structured output — without sending anything to an external API:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Ollama Node: Categorize Ticket
- Model: qwen2.5:7b
- Prompt: "Categorize this support ticket into one of: Billing, Technical, 
  General Inquiry, Complaint. Ticket subject: . 
  Respond with only the category name."
- Output: category field added to the record
</code></pre></div></div>

<p>The key principle: use AI where judgment is needed, use rules where logic is sufficient. Most pipelines are 90% rules and 10% AI. Don’t reach for an LLM when a simple if-statement will do.</p>

<h2 id="pipeline-patterns-that-work">Pipeline Patterns That Work</h2>

<p>Over many implementations, a few patterns consistently deliver value. Here are the ones worth building first:</p>

<h3 id="the-sync-pipeline">The Sync Pipeline</h3>

<p>Keep two systems in sync so that a change in one propagates to the other. This is the most common pattern. Example: customer contact details updated in CRM → updated in accounting → updated in mailing list. The pipeline runs on a webhook trigger (real-time) or a schedule (every 15 minutes).</p>

<h3 id="the-aggregation-pipeline">The Aggregation Pipeline</h3>

<p>Pull data from multiple sources, combine it, and load it into a single dashboard or report. Example: daily sales from your e-commerce platform, daily expenses from your accounting system, and daily website traffic from your analytics tool → combined into a single daily business summary delivered to your inbox every morning at 7 AM.</p>

<h3 id="the-alerting-pipeline">The Alerting Pipeline</h3>

<p>Monitor a data source for specific conditions and notify a human when they’re met. Example: inventory levels checked every hour → if any product drops below reorder threshold → notification sent to purchasing team with a draft purchase order. The pipeline doesn’t replace human decision-making; it surfaces the information that needs a decision.</p>

<h3 id="the-onboarding-pipeline">The Onboarding Pipeline</h3>

<p>When a new customer, employee, or project is created, trigger a series of steps across multiple systems. Example: new employee in HR system → account created in email platform → access granted to project management tool → welcome email sent → equipment request submitted to IT. One trigger, many downstream actions.</p>

<h2 id="common-pitfalls">Common Pitfalls</h2>

<p>A few things tend to go wrong when businesses first start building data pipelines:</p>

<p><strong>Not handling failures gracefully.</strong> A pipeline that fails silently is worse than no pipeline at all — you think the data is synced, but it isn’t. Every pipeline needs error handling: retry logic, logging, and human notification on persistent failure. n8n handles retries natively, but you need to configure the notification step yourself.</p>

<p><strong>Over-transforming data.</strong> It’s tempting to clean, enrich, and restructure data at every step. But each transformation adds complexity and potential failure points. Transform only what’s necessary to make the data usable at the destination. If the destination system can handle the raw data, let it.</p>

<p><strong>Ignoring data volume.</strong> A pipeline that processes 10 records a day works fine with a simple sequential flow. A pipeline that processes 10,000 records a day needs batching, parallel processing, and rate limiting. Build for your current volume, but know where the breaking point is.</p>

<p><strong>No monitoring.</strong> Pipelines degrade over time. APIs change, data formats drift, rate limits shift. Set up a simple dashboard that shows pipeline run counts, success rates, and processing times. If a pipeline that usually runs 50 times a day suddenly runs 3, you want to know immediately.</p>

<h2 id="the-cost-question">The Cost Question</h2>

<p>A self-hosted data pipeline stack costs roughly:</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Monthly Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VPS (8GB RAM, runs n8n + Ollama + PostgreSQL)</td>
      <td>$10–$20</td>
    </tr>
    <tr>
      <td>Odoo Community Edition (self-hosted)</td>
      <td>$0 (open source)</td>
    </tr>
    <tr>
      <td>n8n (self-hosted)</td>
      <td>$0 (open source)</td>
    </tr>
    <tr>
      <td>PostgreSQL (self-hosted)</td>
      <td>$0 (open source)</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>$10–$20/month</strong></td>
    </tr>
  </tbody>
</table>

<p>The equivalent SaaS setup — Zapier for workflow automation, a cloud CRM, a cloud accounting tool with API access — typically runs $200–$500/month for a small team, with per-task fees that scale with usage. The open source stack costs a fraction of that, runs on infrastructure you control, and has no per-task limits.</p>

<h2 id="where-to-start">Where to Start</h2>

<p>Don’t try to connect everything at once. Pick the single most painful manual data transfer in your business — the one that someone does every day, takes 20+ minutes, and is error-prone. Build that pipeline first. Get it running reliably for two weeks. Then pick the next one.</p>

<p>The first pipeline is the hardest because you’re learning the tools. The second is easier. By the fifth, you have patterns to copy and the whole process takes an afternoon instead of a week. This is how automation compounds — not in one dramatic transformation, but in a series of pipelines that each remove a small piece of manual work until one day you realize your team hasn’t exported a CSV in months.</p>

<hr />

<p><em>Your business data shouldn’t require human beings to copy and paste it between systems. ARDOT Consulting designs and builds self-hosted data pipelines using open-source tools — connecting your CRM, accounting, inventory, and analytics without per-task fees or vendor lock-in. <a href="/#contact">Get in touch</a> and we’ll map out the pipelines that would save your team the most time.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="data-pipelines" /><category term="integration" /><category term="n8n" /><category term="etl" /><category term="open-source" /><category term="automation" /><category term="data-flow" /><category term="tutorial" /><summary type="html"><![CDATA[Your business data lives in five different tools that don't talk to each other. Here's how to build automated data pipelines that move information between your systems — no code, no SaaS connectors, no manual exports.]]></summary></entry><entry><title type="html">AI Automation for HR: Recruitment, Screening, and Onboarding</title><link href="https://www.ardotconsulting.com/blog/2026/09/26/ai-automation-for-hr-recruitment-screening-and-onboarding/" rel="alternate" type="text/html" title="AI Automation for HR: Recruitment, Screening, and Onboarding" /><published>2026-09-26T00:00:00-07:00</published><updated>2026-09-26T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/09/26/ai-automation-for-hr-recruitment-screening-and-onboarding</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/09/26/ai-automation-for-hr-recruitment-screening-and-onboarding/"><![CDATA[<h1 id="ai-automation-for-hr-recruitment-screening-and-onboarding">AI Automation for HR: Recruitment, Screening, and Onboarding</h1>

<p>If you’ve ever posted a job opening and watched 300 resumes flood your inbox within 48 hours, you know the problem. Someone has to read every single one. Someone has to schedule phone screens with the promising candidates. Someone has to chase down offer letters, tax forms, and policy acknowledgments. And that someone is usually you — or an HR team already drowning in other work.</p>

<p>Human resources is one of the most automation-friendly functions in any business. The processes are repetitive, document-heavy, and rule-based. But HR is also where data privacy matters most. Resumes contain personal information. Onboarding involves tax documents and identification. Sending all of that to a third-party AI service isn’t just expensive — it’s a compliance risk.</p>

<p>That’s where open source AI tools change the equation. You can run the entire pipeline on your own infrastructure: resume parsing with local AI models, automated screening with custom criteria, interview scheduling that syncs with your calendar, and onboarding workflows that generate and track paperwork automatically. No data leaves your servers.</p>

<p>This guide walks through how to build that pipeline using tools you can self-host today.</p>

<h2 id="the-hr-automation-landscape">The HR Automation Landscape</h2>

<p>Before diving into implementation, let’s map the territory. A typical hiring process has five stages, and each one has automation potential:</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>Manual Process</th>
      <th>Automated Approach</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Job posting</strong></td>
      <td>Copy-paste to multiple boards</td>
      <td>n8n workflow distributes to multiple channels</td>
    </tr>
    <tr>
      <td><strong>Resume screening</strong></td>
      <td>Human reads every CV</td>
      <td>Ollama-powered parser scores and ranks resumes</td>
    </tr>
    <tr>
      <td><strong>Interview scheduling</strong></td>
      <td>Email back-and-forth</td>
      <td>Cal.com integration with automated booking</td>
    </tr>
    <tr>
      <td><strong>Assessment</strong></td>
      <td>Manual test grading</td>
      <td>Automated scoring with custom rubrics</td>
    </tr>
    <tr>
      <td><strong>Onboarding</strong></td>
      <td>Paperwork, accounts, equipment</td>
      <td>n8n orchestrates document generation and provisioning</td>
    </tr>
  </tbody>
</table>

<p>The goal isn’t to remove humans from hiring. It’s to remove the paperwork and scheduling friction so your team can focus on the parts that actually require judgment — interviews, culture fit, and final decisions.</p>

<h2 id="tool-stack-overview">Tool Stack Overview</h2>

<p>Here’s what we’ll use and why:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Role</th>
      <th>Why Open Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>n8n</strong></td>
      <td>Workflow orchestration</td>
      <td>Self-hosted, no per-task pricing, visual builder</td>
    </tr>
    <tr>
      <td><strong>Ollama</strong></td>
      <td>Local LLM inference</td>
      <td>Resumes and employee data never leave your server</td>
    </tr>
    <tr>
      <td><strong>Cal.com</strong></td>
      <td>Interview scheduling</td>
      <td>Open-source scheduling, integrates with n8n</td>
    </tr>
    <tr>
      <td><strong>Directus</strong></td>
      <td>Candidate database</td>
      <td>Headless CMS that stores applicant data securely</td>
    </tr>
    <tr>
      <td><strong>Mattermost</strong></td>
      <td>Team notifications</td>
      <td>Open source Slack alternative for hiring team alerts</td>
    </tr>
  </tbody>
</table>

<p>All five can run on a single server with 16GB RAM. Ollama is the heaviest component — it needs a GPU for fast inference, but CPU-only mode works fine for the batch processing that resume screening involves.</p>

<h2 id="stage-1-resume-screening-with-ollama">Stage 1: Resume Screening with Ollama</h2>

<p>The first bottleneck in any hiring process is resume triage. When you get 200 applications for one role, reading them all is a full day’s work. Here’s how to automate it.</p>

<h3 id="setting-up-the-parser">Setting Up the Parser</h3>

<p>First, install Ollama and pull a model suitable for text analysis:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Ollama</span>
curl <span class="nt">-fsSL</span> https://ollama.com/install.sh | sh

<span class="c"># Pull a model — Qwen2.5 works well for structured extraction</span>
ollama pull qwen2.5:7b
</code></pre></div></div>

<p>Next, write a prompt that extracts structured data from each resume. The key is to ask for JSON output with consistent fields:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">json</span>
<span class="kn">import</span> <span class="n">subprocess</span>

<span class="k">def</span> <span class="nf">screen_resume</span><span class="p">(</span><span class="n">resume_text</span><span class="p">,</span> <span class="n">job_description</span><span class="p">):</span>
    <span class="n">prompt</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"""</span><span class="s">
You are an HR assistant. Evaluate this resume against the job description.
Return a JSON object with these fields:
- name: candidate name
- email: contact email
- years_experience: estimated years of relevant experience
- skills_matched: list of required skills found
- skills_missing: list of required skills not found
- score: 0-100, how well the resume matches the job
- summary: 2-3 sentence assessment
- recommendation: </span><span class="sh">"</span><span class="s">interview</span><span class="sh">"</span><span class="s">, </span><span class="sh">"</span><span class="s">maybe</span><span class="sh">"</span><span class="s">, or </span><span class="sh">"</span><span class="s">reject</span><span class="sh">"</span><span class="s">

Job Description:
</span><span class="si">{</span><span class="n">job_description</span><span class="si">}</span><span class="s">

Resume:
</span><span class="si">{</span><span class="n">resume_text</span><span class="si">}</span><span class="s">
</span><span class="sh">"""</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span>
        <span class="p">[</span><span class="sh">"</span><span class="s">ollama</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">run</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">qwen2.5:7b</span><span class="sh">"</span><span class="p">,</span> <span class="n">prompt</span><span class="p">],</span>
        <span class="n">capture_output</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">60</span>
    <span class="p">)</span>
    <span class="k">return</span> <span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="nf">strip</span><span class="p">())</span>

<span class="c1"># Example usage
</span><span class="n">resume</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">candidate_resume.txt</span><span class="sh">"</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">job</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">job_description.txt</span><span class="sh">"</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">evaluation</span> <span class="o">=</span> <span class="nf">screen_resume</span><span class="p">(</span><span class="n">resume</span><span class="p">,</span> <span class="n">job</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">evaluation</span><span class="p">[</span><span class="sh">'</span><span class="s">name</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="s">: Score </span><span class="si">{</span><span class="n">evaluation</span><span class="p">[</span><span class="sh">'</span><span class="s">score</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="s"> — </span><span class="si">{</span><span class="n">evaluation</span><span class="p">[</span><span class="sh">'</span><span class="s">recommendation</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="batch-processing-with-n8n">Batch Processing with n8n</h3>

<p>Running this one resume at a time is fine for testing, but for production you want a workflow. Here’s how to build it in n8n:</p>

<ol>
  <li><strong>Trigger node</strong>: Watch an email inbox (or a shared drive folder) for new resume attachments</li>
  <li><strong>PDF extraction node</strong>: Use a function node with <code class="language-plaintext highlighter-rouge">pdfplumber</code> to extract text from PDF resumes</li>
  <li><strong>Ollama node</strong>: Send the extracted text to your local Ollama instance via HTTP request</li>
  <li><strong>Directus node</strong>: Store the parsed candidate data in your Directus database</li>
  <li><strong>Filter node</strong>: Route candidates based on score — above 80 goes to “interview,” 60-80 goes to “maybe,” below 60 gets a polite rejection</li>
  <li><strong>Mattermost node</strong>: Post high-scoring candidates to a <code class="language-plaintext highlighter-rouge">#hiring</code> channel for the team to review</li>
</ol>

<p>Here’s the HTTP request configuration for the Ollama node:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"POST"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:11434/api/generate"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"headers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"Content-Type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"qwen2.5:7b"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"="</span><span class="p">,</span><span class="w">
    </span><span class="nl">"format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"json"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"stream"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">format: "json"</code> parameter tells Ollama to return valid JSON, which makes parsing reliable. No more guessing where the model’s output starts and ends.</p>

<h3 id="handling-bias-and-fairness">Handling Bias and Fairness</h3>

<p>This is where you need to be careful. An LLM can inherit biases from its training data — it might score resumes from certain universities higher, or penalize career gaps. Here are practical mitigations:</p>

<ul>
  <li><strong>Strip identifying information</strong> before scoring. Remove name, address, and university names from the resume text before sending it to Ollama. This forces the model to evaluate skills and experience only.</li>
  <li><strong>Use the model as a triage tool, not a final decision-maker.</strong> Have it sort candidates into buckets (strong match, possible match, likely not a fit) but always have a human review the “maybe” pile.</li>
  <li><strong>Audit periodically.</strong> Every few weeks, manually review 20 randomly selected resumes that the model scored. Check whether the scoring aligns with your actual hiring outcomes.</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">anonymize_resume</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Remove identifying info before AI screening.</span><span class="sh">"""</span>
    <span class="kn">import</span> <span class="n">re</span>
    <span class="c1"># Remove email addresses
</span>    <span class="n">text</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">'</span><span class="s">\S+@\S+</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">[EMAIL]</span><span class="sh">'</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="c1"># Remove phone numbers
</span>    <span class="n">text</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">'</span><span class="s">\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">[PHONE]</span><span class="sh">'</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="c1"># Remove names (common patterns at top of resume)
</span>    <span class="n">text</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">'</span><span class="s">^[A-Z][a-z]+ [A-Z][a-z]+</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">[NAME]</span><span class="sh">'</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">text</span>
</code></pre></div></div>

<h2 id="stage-2-automated-interview-scheduling">Stage 2: Automated Interview Scheduling</h2>

<p>Once you’ve identified candidates worth interviewing, the scheduling dance begins. “Are you available Tuesday at 2?” “No, how about Wednesday morning?” “I have a meeting then…” — this back-and-forth wastes hours.</p>

<h3 id="calcom-integration">Cal.com Integration</h3>

<p>Cal.com is an open-source scheduling tool you can self-host. It lets candidates book time slots directly, eliminating the email tennis match.</p>

<p>Here’s the setup:</p>

<ol>
  <li><strong>Install Cal.com</strong> via Docker:
```yaml
    <h1 id="docker-composeyml-simplified">docker-compose.yml (simplified)</h1>
    <p>version: “3.8”
services:
  calcom:
 image: calcom/cal.com:latest
 ports:</p>
    <ul>
      <li>“3000:3000”
 environment:</li>
      <li>DATABASE_URL=postgresql://calcom:password@db:5432/calcom</li>
      <li>NEXTAUTH_SECRET=your-secret-here
 depends_on:</li>
      <li>db
  db:
 image: postgres:16
 environment:</li>
      <li>POSTGRES_USER=calcom</li>
      <li>POSTGRES_PASSWORD=password</li>
      <li>POSTGRES_DB=calcom
```</li>
    </ul>
  </li>
  <li>
    <p><strong>Create event types</strong> for different interview stages: 15-minute phone screen, 45-minute technical interview, 30-minute culture fit call.</p>
  </li>
  <li><strong>Connect to n8n</strong>: When Cal.com receives a booking, it sends a webhook to n8n. The workflow then:
    <ul>
      <li>Creates a candidate record in Directus (or updates an existing one)</li>
      <li>Sends a confirmation email to the candidate</li>
      <li>Notifies the interviewer via Mattermost</li>
      <li>Generates a interview prep sheet by pulling the candidate’s parsed resume data</li>
    </ul>
  </li>
  <li><strong>Automated reminders</strong>: n8n sends a reminder 24 hours before the interview to both the candidate and the interviewer, with the video link and the candidate’s summary.</li>
</ol>

<h3 id="the-n8n-workflow-for-scheduling">The n8n Workflow for Scheduling</h3>

<p>Here’s what the webhook-triggered workflow looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Cal.com Webhook → 
  Filter (event_type = "phone_screen") → 
    Directus (update candidate status to "scheduled") →
      Email (send confirmation to candidate) →
        Mattermost (notify interviewer with prep sheet) →
          Schedule (set reminder for 24h before)
</code></pre></div></div>

<p>The interview prep sheet is generated by sending the candidate’s parsed resume data back to Ollama with a prompt like: “Generate 5 interview questions tailored to this candidate’s background and the job requirements.”</p>

<h2 id="stage-3-onboarding-automation">Stage 3: Onboarding Automation</h2>

<p>Onboarding is where automation delivers the most time savings. A new hire requires 15-30 administrative tasks across different systems — and missing one can mean compliance issues or a poor first-day experience.</p>

<h3 id="building-the-onboarding-workflow">Building the Onboarding Workflow</h3>

<p>Here’s a comprehensive n8n workflow that triggers when a candidate is marked as “hired” in Directus:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Directus (status = "hired") →
  ┌─ Email (welcome email with first-day instructions)
  ├─ Directus (create employee record)
  ├─ Document Generation (offer letter, NDA, tax forms)
  ├─ Cal.com (schedule orientation sessions)
  ├─ Mattermost (create #welcome-[name] channel, notify team)
  ├─ Odoo (create HR record, assign to department)
  └─ Schedule (day-1, day-7, day-30 check-in reminders)
</code></pre></div></div>

<h3 id="document-generation">Document Generation</h3>

<p>Onboarding paperwork is a perfect candidate for automation. Most offer letters, NDAs, and policy acknowledgments are template documents with variable fields (name, start date, salary, role). Here’s how to generate them automatically:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">docx</span> <span class="kn">import</span> <span class="n">Document</span>
<span class="kn">from</span> <span class="n">datetime</span> <span class="kn">import</span> <span class="n">datetime</span>

<span class="k">def</span> <span class="nf">generate_offer_letter</span><span class="p">(</span><span class="n">candidate_data</span><span class="p">,</span> <span class="n">template_path</span><span class="o">=</span><span class="sh">"</span><span class="s">templates/offer_letter.docx</span><span class="sh">"</span><span class="p">):</span>
    <span class="n">doc</span> <span class="o">=</span> <span class="nc">Document</span><span class="p">(</span><span class="n">template_path</span><span class="p">)</span>
    
    <span class="n">replacements</span> <span class="o">=</span> <span class="p">{</span>
        <span class="sh">""</span><span class="p">:</span> <span class="n">candidate_data</span><span class="p">[</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">],</span>
        <span class="sh">""</span><span class="p">:</span> <span class="n">candidate_data</span><span class="p">[</span><span class="sh">"</span><span class="s">position</span><span class="sh">"</span><span class="p">],</span>
        <span class="sh">""</span><span class="p">:</span> <span class="sa">f</span><span class="sh">"</span><span class="s">$</span><span class="si">{</span><span class="n">candidate_data</span><span class="p">[</span><span class="sh">'</span><span class="s">salary</span><span class="sh">'</span><span class="p">]</span><span class="si">:</span><span class="p">,</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span>
        <span class="sh">""</span><span class="p">:</span> <span class="n">candidate_data</span><span class="p">[</span><span class="sh">"</span><span class="s">start_date</span><span class="sh">"</span><span class="p">],</span>
        <span class="sh">""</span><span class="p">:</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">().</span><span class="nf">strftime</span><span class="p">(</span><span class="sh">"</span><span class="s">%B %d, %Y</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">}</span>
    
    <span class="k">for</span> <span class="n">paragraph</span> <span class="ow">in</span> <span class="n">doc</span><span class="p">.</span><span class="n">paragraphs</span><span class="p">:</span>
        <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">replacements</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
            <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">paragraph</span><span class="p">.</span><span class="n">text</span><span class="p">:</span>
                <span class="n">paragraph</span><span class="p">.</span><span class="n">text</span> <span class="o">=</span> <span class="n">paragraph</span><span class="p">.</span><span class="n">text</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
    
    <span class="n">output_path</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="s">output/offer_letter_</span><span class="si">{</span><span class="n">candidate_data</span><span class="p">[</span><span class="sh">'</span><span class="s">name</span><span class="sh">'</span><span class="p">].</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s"> </span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">_</span><span class="sh">'</span><span class="p">)</span><span class="si">}</span><span class="s">.docx</span><span class="sh">"</span>
    <span class="n">doc</span><span class="p">.</span><span class="nf">save</span><span class="p">(</span><span class="n">output_path</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">output_path</span>

<span class="c1"># Example
</span><span class="n">candidate</span> <span class="o">=</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Jane Smith</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">position</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Operations Manager</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">salary</span><span class="sh">"</span><span class="p">:</span> <span class="mi">75000</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">start_date</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">October 15, 2026</span><span class="sh">"</span>
<span class="p">}</span>
<span class="n">path</span> <span class="o">=</span> <span class="nf">generate_offer_letter</span><span class="p">(</span><span class="n">candidate</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Offer letter generated: </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>In n8n, this runs as a function node. The generated document gets stored in Directus and emailed to the candidate for e-signature.</p>

<h3 id="the-30-60-90-day-check-in">The 30-60-90 Day Check-In</h3>

<p>Onboarding doesn’t end on day one. A good process includes check-ins at 30, 60, and 90 days. n8n can automate these:</p>

<ul>
  <li><strong>Day 30</strong>: Send a structured feedback form to the new hire and their manager. Use Ollama to summarize the responses and flag any concerns.</li>
  <li><strong>Day 60</strong>: Schedule a one-on-one via Cal.com. Send the manager a summary of the 30-day feedback.</li>
  <li><strong>Day 90</strong>: Generate a probationary review document. If both the manager and employee responses are positive, trigger the “confirm permanent role” workflow. If concerns are flagged, alert HR for a manual review.</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Schedule (30 days after start) →
  Email (send feedback form to employee) →
  Email (send feedback form to manager) →
  Wait (7 days) →
  Ollama (summarize responses, flag concerns) →
  Directus (store review) →
  Mattermost (notify HR if concerns flagged)
</code></pre></div></div>

<h2 id="measuring-the-impact">Measuring the Impact</h2>

<p>How much time does this actually save? Let’s break it down with realistic numbers for a company hiring 2-3 people per month:</p>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Manual Time</th>
      <th>Automated Time</th>
      <th>Monthly Savings</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Resume screening (200/month)</td>
      <td>16 hours</td>
      <td>1 hour (review only)</td>
      <td>15 hours</td>
    </tr>
    <tr>
      <td>Interview scheduling (15/month)</td>
      <td>5 hours</td>
      <td>0.5 hours</td>
      <td>4.5 hours</td>
    </tr>
    <tr>
      <td>Onboarding paperwork (3/month)</td>
      <td>6 hours</td>
      <td>1 hour</td>
      <td>5 hours</td>
    </tr>
    <tr>
      <td>Check-in follow-ups</td>
      <td>3 hours</td>
      <td>0.5 hours</td>
      <td>2.5 hours</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>30 hours</strong></td>
      <td><strong>3 hours</strong></td>
      <td><strong>27 hours/month</strong></td>
    </tr>
  </tbody>
</table>

<p>27 hours per month — that’s more than three full work days reclaimed. For a solo HR person or a business owner handling hiring themselves, that’s the difference between drowning and staying afloat.</p>

<p>And that’s before counting the qualitative improvements: faster response times to candidates (better hiring outcomes), no missed onboarding steps (better retention), and consistent processes (easier compliance).</p>

<h2 id="getting-started-a-minimal-viable-pipeline">Getting Started: A Minimal Viable Pipeline</h2>

<p>You don’t need to build everything at once. Here’s a pragmatic rollout:</p>

<p><strong>Week 1</strong>: Install Ollama and test resume screening with 10 sample resumes. Tune the prompt until the scoring matches your judgment.</p>

<p><strong>Week 2</strong>: Set up n8n and connect it to your email inbox. Automate resume intake — even if you just use it to sort candidates into folders, that alone saves hours.</p>

<p><strong>Week 3</strong>: Install Cal.com and create interview slots. Replace email-based scheduling with self-serve booking.</p>

<p><strong>Week 4</strong>: Build the onboarding workflow. Start with just the welcome email and document generation. Add the 30-60-90 day check-ins in week 5.</p>

<p>The beauty of this stack is that each piece works independently. You can adopt them one at a time and still see immediate benefits.</p>

<h2 id="common-pitfalls">Common Pitfalls</h2>

<p><strong>Over-automating rejection emails.</strong> Candidates talk. If every rejection is an obviously automated template, your employer brand suffers. Use Ollama to draft personalized rejection emails that reference something specific from the candidate’s resume, and have a human review them before sending — or at least the ones for candidates who made it to the interview stage.</p>

<p><strong>Ignoring data retention.</strong> Resumes contain personal data. In many jurisdictions, you’re legally required to delete candidate data after a certain period (6-24 months depending on your location). Build a n8n workflow that automatically purges rejected candidate records from Directus after your retention period expires.</p>

<p><strong>Treating AI scores as gospel.</strong> The Ollama model will occasionally score a great candidate low because their resume uses different terminology than the job description. Always have a human skim the top 20% of the “maybe” pile. The model is a filter, not an oracle.</p>

<p><strong>Forgetting the candidate experience.</strong> Automation should make things faster for candidates too, not just for you. Automated confirmations, clear next steps, and timely updates (even automated ones) make candidates feel respected. Ghosting — even automated ghosting — damages your reputation.</p>

<h2 id="conclusion">Conclusion</h2>

<p>HR automation isn’t about replacing human judgment in hiring. It’s about removing the administrative weight that makes hiring painful. When your team spends 27 fewer hours per month on paperwork and scheduling, they can spend that time actually talking to candidates, evaluating culture fit, and making better hiring decisions.</p>

<p>The open source stack — n8n, Ollama, Cal.com, Directus, Mattermost — gives you enterprise-grade automation without sending sensitive employee data to third-party servers. You own the infrastructure. You control the data. You set the rules.</p>

<p>Start small. Automate resume screening first. Then scheduling. Then onboarding. Each step compounds, and within a month you’ll have a hiring process that feels less like administrative drudgery and more like what HR is supposed to be: finding the right people and helping them succeed.</p>

<hr />

<p><em>Want help setting up an automated HR pipeline for your business? ARDOT Consulting specializes in open source AI automation for small and mid-size businesses. <a href="/">Contact us</a> to schedule a free consultation — we’ll map your current hiring process and show you exactly where automation can save you time.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="hr" /><category term="recruitment" /><category term="automation" /><category term="onboarding" /><category term="ai" /><category term="open-source" /><summary type="html"><![CDATA[How small and mid-size businesses can automate resume screening, interview scheduling, and onboarding paperwork using open source AI tools — without sending sensitive employee data to third-party APIs.]]></summary></entry><entry><title type="html">Automating Email Triage with Open-Source AI: Sort Your Inbox Without a SaaS</title><link href="https://www.ardotconsulting.com/blog/2026/09/26/automating-email-triage-with-open-source-ai-sort-your-inbox-without-saas/" rel="alternate" type="text/html" title="Automating Email Triage with Open-Source AI: Sort Your Inbox Without a SaaS" /><published>2026-09-26T00:00:00-07:00</published><updated>2026-09-26T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/09/26/automating-email-triage-with-open-source-ai-sort-your-inbox-without-saas</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/09/26/automating-email-triage-with-open-source-ai-sort-your-inbox-without-saas/"><![CDATA[<p>If you run a small business, your inbox is probably the single biggest drain on your day. Not because the emails are hard — most of them aren’t — but because there are so many of them, and sorting the important ones from the noise takes real mental effort. A client question buried under twelve promotional newsletters. A supplier invoice sitting in the same folder as a meeting confirmation. A support request that should have been answered an hour ago but got pushed below the fold.</p>

<p>The standard advice is “use a smart inbox” — Superhuman, SaneBox, one of the dozens of AI email tools that charge $15–$30 per user per month and, critically, require you to pipe your entire email history through their servers. For a solo operator that’s an annoying cost. For a law firm, a financial advisory, a healthcare practice, or any business that handles sensitive client communications, it’s a non-starter. Your email contains contracts, client data, financial documents. Sending it to a third-party SaaS to be categorized is a privacy decision dressed up as a productivity one.</p>

<p>This guide walks through a different approach: a self-hosted email triage system built with <strong>Ollama</strong> (for local AI) and <strong>n8n</strong> (for workflow automation) that reads your inbox, categorizes each message, prioritizes what matters, and drafts replies — all on your own server. No API fees. No data leaving your infrastructure. No per-seat pricing. You can run it on a $40/month VPS for the whole company.</p>

<h2 id="what-were-building">What We’re Building</h2>

<p>The goal isn’t to replace your email client. It’s to add a layer on top of it that does the triage work for you. Specifically, the system will:</p>

<ol>
  <li><strong>Check your inbox on a schedule</strong> (every 10–15 minutes via IMAP)</li>
  <li><strong>Read each new message</strong> and classify it into a category — Support Request, Client Communication, Invoice/Payment, Internal, Newsletter/Promotional, or Urgent</li>
  <li><strong>Assign a priority</strong> (High, Medium, Low) based on the sender, content, and urgency signals</li>
  <li><strong>Move or tag the email</strong> in your mailbox so your inbox only shows what actually needs your attention</li>
  <li><strong>Draft a suggested reply</strong> for messages that need a response, stored alongside the email for your review</li>
</ol>

<p>The AI processing happens locally via Ollama. The workflow orchestration happens in n8n. Your email stays on your mail server and your VPS — it never touches a third-party API.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need:</p>

<ul>
  <li><strong>A VPS or local server</strong> with at least 8GB RAM (16GB recommended if you’re running a larger model). A basic Hetzner or OVH cloud server works fine. No GPU required — we’ll use a smaller, efficient model.</li>
  <li><strong>Docker and Docker Compose</strong> installed</li>
  <li><strong>An email account with IMAP access</strong> — this works with self-hosted mail servers (Mailcow, Poste.io, Postal) as well as standard IMAP providers</li>
  <li><strong>About an hour</strong> to set everything up</li>
</ul>

<h2 id="step-1-set-up-ollama">Step 1: Set Up Ollama</h2>

<p>If you followed our <a href="/blog/2026/08/20/running-local-llms-with-ollama-a-practical-guide-for-businesses/">Ollama guide</a>, you already have this. If not, the quick version:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Ollama</span>
curl <span class="nt">-fsSL</span> https://ollama.com/install.sh | sh

<span class="c"># Pull a model suitable for classification and short text tasks</span>
ollama pull llama3.2:3b

<span class="c"># Verify it's running</span>
ollama run llama3.2:3b <span class="s2">"Say hello in one sentence."</span>
</code></pre></div></div>

<p>We’re using Llama 3.2 3B here because email triage is a classification task, not a creative writing task. You don’t need a 70-billion-parameter model to figure out whether an email is an invoice or a newsletter. The 3B model runs comfortably on CPU, responds in under 2 seconds per email, and costs nothing per inference. If you have a GPU or more RAM, <code class="language-plaintext highlighter-rouge">qwen2.5:7b</code> will give you slightly better accuracy on ambiguous messages.</p>

<h2 id="step-2-set-up-n8n">Step 2: Set Up n8n</h2>

<p>Again, if you already have n8n running, skip ahead. For a fresh install via Docker Compose:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># docker-compose.yml</span>
<span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>
<span class="na">services</span><span class="pi">:</span>
  <span class="na">n8n</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">n8nio/n8n:latest</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">5678:5678"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">N8N_HOST=0.0.0.0</span>
      <span class="pi">-</span> <span class="s">N8N_PORT=5678</span>
      <span class="pi">-</span> <span class="s">N8N_PROTOCOL=https</span>
      <span class="pi">-</span> <span class="s">WEBHOOK_URL=https://n8n.yourdomain.com/</span>
      <span class="pi">-</span> <span class="s">N8N_BASIC_AUTH_ACTIVE=true</span>
      <span class="pi">-</span> <span class="s">N8N_BASIC_AUTH_USER=admin</span>
      <span class="pi">-</span> <span class="s">N8N_BASIC_AUTH_PASSWORD=your-strong-password</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">n8n_data:/home/node/.n8n</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">n8n_data</span><span class="pi">:</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Navigate to <code class="language-plaintext highlighter-rouge">https://n8n.yourdomain.com/</code> (behind a reverse proxy with TLS — we recommend Caddy for automatic HTTPS) and log in.</p>

<h2 id="step-3-build-the-triage-workflow">Step 3: Build the Triage Workflow</h2>

<p>This is the core of the system. In n8n, create a new workflow with these nodes:</p>

<h3 id="node-1-imap-trigger-schedule">Node 1: IMAP Trigger (Schedule)</h3>

<p>Use the <strong>Email Read IMAP</strong> node (or the IMAP trigger in newer n8n versions):</p>

<ul>
  <li><strong>Host:</strong> <code class="language-plaintext highlighter-rouge">imap.yourmailserver.com</code></li>
  <li><strong>Port:</strong> 993</li>
  <li><strong>SSL:</strong> enabled</li>
  <li><strong>Username / Password:</strong> your email credentials</li>
  <li><strong>Mailbox:</strong> <code class="language-plaintext highlighter-rouge">INBOX</code></li>
  <li><strong>Post Process:</strong> <code class="language-plaintext highlighter-rouge">Mark as read</code> — but <strong>don’t</strong> move or delete yet</li>
  <li><strong>Schedule:</strong> every 15 minutes</li>
</ul>

<p>This node polls your inbox at the interval you set and outputs one item per new unread message. Each item contains the subject, sender, body text, and date.</p>

<h3 id="node-2-http-request--ollama">Node 2: HTTP Request → Ollama</h3>

<p>Add an <strong>HTTP Request</strong> node that sends each email to your local Ollama instance for classification. Ollama exposes a simple REST API at <code class="language-plaintext highlighter-rouge">http://localhost:11434/api/generate</code>.</p>

<p>Configure it as a POST request:</p>

<ul>
  <li><strong>URL:</strong> <code class="language-plaintext highlighter-rouge">http://host.docker.internal:11434/api/generate</code> (or your Ollama server’s IP)</li>
  <li><strong>Method:</strong> POST</li>
  <li><strong>Body (JSON):</strong></li>
</ul>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"llama3.2:3b"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"You are an email triage assistant. Read the email below and respond with ONLY a JSON object, no markdown, no explanation.</span><span class="se">\n\n</span><span class="s2">Email Subject: </span><span class="se">\n</span><span class="s2">Email Sender: </span><span class="se">\n</span><span class="s2">Email Body: </span><span class="se">\n\n</span><span class="s2">Respond in this exact format:</span><span class="se">\n</span><span class="s2">{</span><span class="se">\"</span><span class="s2">category</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">&lt;one of: Support Request, Client Communication, Invoice/Payment, Internal, Newsletter/Promotional, Urgent&gt;</span><span class="se">\"</span><span class="s2">, </span><span class="se">\"</span><span class="s2">priority</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">&lt;one of: High, Medium, Low&gt;</span><span class="se">\"</span><span class="s2">, </span><span class="se">\"</span><span class="s2">needs_reply</span><span class="se">\"</span><span class="s2">: &lt;true or false&gt;, </span><span class="se">\"</span><span class="s2">suggested_reply</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">&lt;a brief professional reply if needs_reply is true, otherwise empty string&gt;</span><span class="se">\"</span><span class="s2">, </span><span class="se">\"</span><span class="s2">summary</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">&lt;one sentence summary&gt;</span><span class="se">\"</span><span class="s2">}"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"stream"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"options"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.3</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>A few notes on the prompt:</p>

<ul>
  <li><strong>Temperature 0.3</strong> keeps the model’s output consistent. You don’t want creative variation in a classification task — you want the same email to get the same category every time.</li>
  <li><strong>Truncating the body to 2000 characters</strong> keeps inference fast and avoids context limits. For 95% of emails, the first paragraph plus the subject is enough to classify correctly.</li>
  <li><strong>The strict JSON-only instruction</strong> is important. Llama 3.2 will sometimes wrap output in markdown code fences. Adding “no markdown, no explanation” and parsing defensively (stripping ```json fences if present) handles the edge cases.</li>
</ul>

<h3 id="node-3-parse-the-json-response">Node 3: Parse the JSON Response</h3>

<p>Add a <strong>Code</strong> node (JavaScript) to parse Ollama’s response and handle any formatting quirks:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">let</span> <span class="nx">rawResponse</span> <span class="o">=</span> <span class="nx">$input</span><span class="p">.</span><span class="nf">first</span><span class="p">().</span><span class="nx">json</span><span class="p">.</span><span class="nx">response</span><span class="p">;</span>
<span class="kd">let</span> <span class="nx">parsed</span><span class="p">;</span>

<span class="k">try</span> <span class="p">{</span>
  <span class="c1">// Strip markdown code fences if the model added them</span>
  <span class="kd">let</span> <span class="nx">cleaned</span> <span class="o">=</span> <span class="nx">rawResponse</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/```json</span><span class="se">\n?</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/```/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">).</span><span class="nf">trim</span><span class="p">();</span>
  <span class="nx">parsed</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">cleaned</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// Fallback: treat as uncategorized</span>
  <span class="nx">parsed</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">category</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Uncategorized</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">priority</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Medium</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">needs_reply</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="na">suggested_reply</span><span class="p">:</span> <span class="dl">''</span><span class="p">,</span>
    <span class="na">summary</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Could not parse AI response</span><span class="dl">'</span>
  <span class="p">};</span>
<span class="p">}</span>

<span class="k">return</span> <span class="p">{</span>
  <span class="na">json</span><span class="p">:</span> <span class="p">{</span>
    <span class="p">...</span><span class="nx">parsed</span><span class="p">,</span>
    <span class="na">original_subject</span><span class="p">:</span> <span class="nf">$items</span><span class="p">(</span><span class="dl">'</span><span class="s1">Email Read IMAP</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="nx">json</span><span class="p">.</span><span class="nx">subject</span><span class="p">,</span>
    <span class="na">original_from</span><span class="p">:</span> <span class="nf">$items</span><span class="p">(</span><span class="dl">'</span><span class="s1">Email Read IMAP</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="nx">json</span><span class="p">.</span><span class="k">from</span><span class="p">.</span><span class="nx">value</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">address</span><span class="p">,</span>
    <span class="na">message_id</span><span class="p">:</span> <span class="nf">$items</span><span class="p">(</span><span class="dl">'</span><span class="s1">Email Read IMAP</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="nx">json</span><span class="p">.</span><span class="nx">messageId</span>
  <span class="p">}</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="node-4-route-based-on-category">Node 4: Route Based on Category</h3>

<p>Add an <strong>IF</strong> node (or Switch node) to route each email based on its category. Here’s where you decide what actually happens:</p>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Urgent</strong></td>
      <td>Move to <code class="language-plaintext highlighter-rouge">Urgent</code> folder, send a Slack/Matrix notification immediately</td>
    </tr>
    <tr>
      <td><strong>Support Request</strong></td>
      <td>Move to <code class="language-plaintext highlighter-rouge">Support</code> folder, draft reply, notify support channel</td>
    </tr>
    <tr>
      <td><strong>Client Communication</strong></td>
      <td>Move to <code class="language-plaintext highlighter-rouge">Clients</code> folder, draft reply for review</td>
    </tr>
    <tr>
      <td><strong>Invoice/Payment</strong></td>
      <td>Move to <code class="language-plaintext highlighter-rouge">Finance</code> folder, notify accounting</td>
    </tr>
    <tr>
      <td><strong>Internal</strong></td>
      <td>Leave in inbox or move to <code class="language-plaintext highlighter-rouge">Internal</code></td>
    </tr>
    <tr>
      <td><strong>Newsletter/Promotional</strong></td>
      <td>Move to <code class="language-plaintext highlighter-rouge">Newsletters</code> folder, no notification</td>
    </tr>
  </tbody>
</table>

<p>The “move” action uses the <strong>Email Move</strong> node (or IMAP Move) in n8n. The notifications use the <strong>Slack</strong> or <strong>Matrix</strong> node — we recommend <a href="https://matrix.org/">Matrix</a> if you want to keep your internal messaging self-hosted too.</p>

<h3 id="node-5-save-draft-replies-optional">Node 5: Save Draft Replies (Optional)</h3>

<p>For emails where <code class="language-plaintext highlighter-rouge">needs_reply</code> is true, you can store the suggested reply rather than sending it automatically. We recommend <strong>never auto-sending</strong> — the AI draft should go into a review queue. You can:</p>

<ul>
  <li>Save the draft to a <strong>Directus</strong> collection (a self-hosted headless CMS) for a simple review dashboard</li>
  <li>Append it to a shared <strong>Nextcloud Notes</strong> or Markdown file</li>
  <li>Send it to a dedicated <code class="language-plaintext highlighter-rouge">Drafts</code> mailbox folder for review in your email client</li>
</ul>

<p>The point is that the AI does the first draft — the slow part — and you do the final review and send. This is the right division of labor. The AI saves you 80% of the effort; you keep 100% of the accountability.</p>

<h2 id="step-4-test-and-tune">Step 4: Test and Tune</h2>

<p>Before turning this loose on your real inbox, test it. Create a test mailbox, forward a sampling of real emails into it, and run the workflow manually. Check:</p>

<ul>
  <li><strong>Classification accuracy:</strong> Are invoices going to Finance? Are newsletters getting filtered out? Watch for false positives — a client email that gets categorized as “Newsletter” because it contains the word “update.”</li>
  <li><strong>Reply quality:</strong> Read the suggested replies. If they’re too generic or too confident, adjust the prompt. Adding “If unsure whether a reply is needed, set needs_reply to false” reduces over-eager drafting.</li>
  <li><strong>Speed:</strong> With Llama 3.2 3B on CPU, each email takes 1–3 seconds to process. If you get 50 emails overnight, that’s under 2 minutes of processing — well within a 15-minute polling interval.</li>
</ul>

<p>A common tuning issue: the model may misclassify transactional emails (order confirmations, shipping notifications) as “Support Request.” Fix this by adding to the prompt: <code class="language-plaintext highlighter-rouge">"Transaction confirmations and shipping notifications are NOT support requests — categorize them as Newsletter/Promotional unless there's a problem indicated."</code> Prompt refinement is an iterative process. Budget an hour for it.</p>

<h2 id="what-this-costs">What This Costs</h2>

<p>Let’s be concrete:</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VPS (8GB RAM, 2 vCPU)</td>
      <td>~$6–$12/month</td>
    </tr>
    <tr>
      <td>Ollama</td>
      <td>Free (open source)</td>
    </tr>
    <tr>
      <td>n8n (self-hosted)</td>
      <td>Free (open source)</td>
    </tr>
    <tr>
      <td>Domain + TLS</td>
      <td>~$10/year</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>~$8–$15/month flat, regardless of team size</strong></td>
    </tr>
  </tbody>
</table>

<p>Compare that to a SaaS email tool at $20/user/month. For a 10-person team, that’s $200/month versus $15. And the SaaS version reads every email you send it. The self-hosted version never lets a message leave your network.</p>

<h2 id="what-this-system-does--and-doesnt--do">What This System Does — and Doesn’t — Do</h2>

<p><strong>It does:</strong></p>
<ul>
  <li>Sort your inbox automatically so you only see what needs a human</li>
  <li>Flag urgent messages so they don’t get lost</li>
  <li>Draft first-pass replies to save you the blank-page problem</li>
  <li>Keep all processing on infrastructure you control</li>
</ul>

<p><strong>It doesn’t:</strong></p>
<ul>
  <li>Send replies on your behalf (and it shouldn’t — that’s a trust boundary you don’t want to cross)</li>
  <li>Handle complex multi-thread negotiations (those still need a human reading the full context)</li>
  <li>Replace your email client (it works alongside it, organizing what’s already there)</li>
  <li>Work without a mail server that supports IMAP (most do, but check yours)</li>
</ul>

<p>The system is a triage layer, not an autoresponder. Think of it as a really fast assistant who pre-sorts your mail and writes draft responses on a notepad for you to review. The assistant never sends anything. You stay in control.</p>

<h2 id="when-to-use-cloud-ai-instead">When to Use Cloud AI Instead</h2>

<p>There’s one honest caveat: if you’re processing thousands of emails per day (a large customer service operation, for example), local inference on CPU may be too slow. At that volume, a GPU server or a cloud inference provider makes economic sense. But for a small business handling 50–200 emails a day — which is most of you reading this — a $10/month VPS with Ollama handles it comfortably.</p>

<p>The other scenario where cloud APIs make sense is if you need a very large model for nuanced classification (legal correspondence, for instance, where the difference between “this is a standard update” and “this is a deadline notice” matters a lot). In that case, running a 70B model locally requires serious hardware. But for standard business email — invoices, support, newsletters, client check-ins — a 3B model is more than enough.</p>

<h2 id="a-note-on-privacy">A Note on Privacy</h2>

<p>This is the part that matters most and gets talked about least. Every email you send to a SaaS email tool is stored on their servers, often used to train or improve their models, and covered by their privacy policy — which can change. You have no audit trail. You can’t inspect what they do with your data.</p>

<p>When you self-host with Ollama, your emails are processed in memory on a server you control and then discarded. There’s no training data retention. There’s no third party with a copy. For businesses in regulated industries — legal, healthcare, finance — this isn’t a nice-to-have. It’s the difference between a defensible compliance posture and a liability.</p>

<h2 id="getting-started">Getting Started</h2>

<p>If you already have n8n and Ollama running from our previous guides, this workflow takes about 45 minutes to build. If you’re starting from scratch, budget an afternoon for the full stack setup. Either way, the payoff is immediate: the first morning after you turn it on, you’ll open your inbox to find it already sorted, with draft replies waiting for the messages that need them.</p>

<p>Email triage is one of the highest-ROI automations a small business can build. It touches every employee, every day, and the manual version of it is pure overhead — work that has to happen but creates no value. Automating it doesn’t just save time. It removes a daily source of friction that makes people dread opening their inbox.</p>

<hr />

<p><em>Want help setting up an email triage system tailored to your business? ARDOT Consulting builds self-hosted automation workflows using open-source tools — no vendor lock-in, no per-seat fees, your data stays on your infrastructure. <a href="/#contact">Get in touch</a> and we’ll map out what’s possible for your team.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="email" /><category term="automation" /><category term="ollama" /><category term="n8n" /><category term="open-source" /><category term="inbox-zero" /><category term="tutorial" /><category term="self-hosting" /><summary type="html"><![CDATA[How to build a self-hosted email triage system that categorizes, prioritizes, and drafts replies to your inbox using Ollama and n8n — no per-seat SaaS fees, no third-party access to your messages.]]></summary></entry><entry><title type="html">Building a Customer Support Chatbot with Open Source AI: A Step-by-Step Guide</title><link href="https://www.ardotconsulting.com/blog/2026/09/24/building-a-customer-support-chatbot-with-open-source-ai/" rel="alternate" type="text/html" title="Building a Customer Support Chatbot with Open Source AI: A Step-by-Step Guide" /><published>2026-09-24T00:00:00-07:00</published><updated>2026-09-24T00:00:00-07:00</updated><id>https://www.ardotconsulting.com/blog/2026/09/24/building-a-customer-support-chatbot-with-open-source-ai</id><content type="html" xml:base="https://www.ardotconsulting.com/blog/2026/09/24/building-a-customer-support-chatbot-with-open-source-ai/"><![CDATA[<p>Every small business owner has been there: the same five questions landing in your inbox every single day. “What are your hours?” “Do you ship internationally?” “How do I reset my password?” “Can I get a refund?” “Where’s my order?”</p>

<p>You don’t need a team of agents to handle these. You don’t need to pay $200/month for a SaaS chatbot platform that sends your customer conversations to a third-party server. And you don’t need to write a single line of Python.</p>

<p>In this guide, we’ll build a customer support chatbot using two open source tools — <strong>Ollama</strong> for the AI brain and <strong>n8n</strong> for the workflow glue — that runs entirely on your own server. No API fees. No data leaving your infrastructure. No vendor lock-in.</p>

<h2 id="what-were-building">What We’re Building</h2>

<p>Here’s the architecture at a glance:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Customer Message (Web/Email)
        │
        ▼
   ┌─────────┐
   │   n8n   │  ← Webhook receives message
   │ Workflow │
   └────┬────┘
        │
        ▼
   ┌─────────┐
   │ Ollama  │  ← Local LLM generates response
   │ (Llama  │    using your knowledge base
   │  3.2)   │
   └────┬────┘
        │
        ▼
   ┌─────────┐
   │   n8n   │  ← Sends response back to
   │ Workflow │    customer via web/email
   └─────────┘
</code></pre></div></div>

<p>The chatbot will:</p>
<ol>
  <li>Accept messages from a web widget or email</li>
  <li>Check a knowledge base of your FAQ documents</li>
  <li>Generate a helpful response using a local LLM</li>
  <li>Escalate to a human when it’s not confident</li>
</ol>

<h2 id="why-open-source-for-customer-support">Why Open Source for Customer Support?</h2>

<p>Before we dive in, let’s address the elephant in the room. There are dozens of SaaS chatbot platforms — Intercom, Zendesk AI, Drift, Tidio. Why build your own?</p>

<table>
  <thead>
    <tr>
      <th>Factor</th>
      <th>SaaS Chatbot</th>
      <th>Self-Hosted (Ollama + n8n)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Monthly cost</td>
      <td>$50–$500+/month</td>
      <td>~$20/month (VPS hosting)</td>
    </tr>
    <tr>
      <td>Data privacy</td>
      <td>Conversations sent to vendor</td>
      <td>Stays on your server</td>
    </tr>
    <tr>
      <td>Customization</td>
      <td>Limited to platform features</td>
      <td>Full control</td>
    </tr>
    <tr>
      <td>Vendor lock-in</td>
      <td>High — migration is painful</td>
      <td>Low — open standards</td>
    </tr>
    <tr>
      <td>Setup time</td>
      <td>1–2 hours</td>
      <td>3–4 hours (one-time)</td>
    </tr>
    <tr>
      <td>Ongoing maintenance</td>
      <td>Vendor handles it</td>
      <td>You handle updates</td>
    </tr>
  </tbody>
</table>

<p>The tradeoff is clear: a bit more setup work in exchange for lower costs, full data ownership, and no dependency on a vendor’s pricing decisions. For businesses handling sensitive customer information — healthcare, legal, finance — keeping data on your own server isn’t just cheaper. It’s often the legally safer option.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need:</p>

<ul>
  <li>A Linux server (VPS) with at least 8GB RAM and 4 CPU cores — a $20/month VPS from Hetzner, OVH, or DigitalOcean works fine</li>
  <li>Docker and Docker Compose installed</li>
  <li>Basic comfort with the terminal (copy-paste level)</li>
  <li>About 30 minutes of your FAQ and policy documents in text form</li>
</ul>

<p>If you’ve never used Docker before, don’t worry — we’ll use <code class="language-plaintext highlighter-rouge">docker-compose</code> files that just work.</p>

<h2 id="step-1-set-up-ollama">Step 1: Set Up Ollama</h2>

<p>Ollama runs large language models locally. Think of it as a lightweight, self-hosted alternative to the OpenAI API — except there’s no API key, no per-token billing, and no data leaving your server.</p>

<p>Create a directory for your chatbot stack:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> ~/support-bot <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/support-bot
</code></pre></div></div>

<p>Create <code class="language-plaintext highlighter-rouge">docker-compose.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">ollama</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ollama/ollama:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">ollama</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">11434:11434"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">ollama_data:/root/.ollama</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">ollama_data</span><span class="pi">:</span>
</code></pre></div></div>

<p>Start Ollama:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Now pull a model. For customer support, <strong>Llama 3.2 (3B parameter)</strong> is an excellent choice — it’s fast, capable enough for FAQ-style responses, and runs comfortably on 8GB RAM:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec</span> <span class="nt">-it</span> ollama ollama pull llama3.2
</code></pre></div></div>

<p>This downloads about 2GB and takes a few minutes. Once it’s done, verify the model works:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl http://localhost:11434/api/generate <span class="nt">-d</span> <span class="s1">'{
  "model": "llama3.2",
  "prompt": "What are your business hours?",
  "stream": false
}'</span>
</code></pre></div></div>

<p>You should get a JSON response with a generated answer. Ollama is running.</p>

<h3 id="choosing-the-right-model">Choosing the Right Model</h3>

<p>Llama 3.2 (3B) is our default recommendation, but here’s how to decide if you need something bigger:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Size</th>
      <th>RAM Needed</th>
      <th>Best For</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Llama 3.2 (3B)</td>
      <td>2GB</td>
      <td>8GB</td>
      <td>FAQ-style support, simple Q&amp;A</td>
    </tr>
    <tr>
      <td>Llama 3.1 (8B)</td>
      <td>4.7GB</td>
      <td>16GB</td>
      <td>Complex responses, multi-turn conversations</td>
    </tr>
    <tr>
      <td>Qwen 2.5 (7B)</td>
      <td>4.7GB</td>
      <td>16GB</td>
      <td>Strong multilingual support</td>
    </tr>
    <tr>
      <td>Mistral (7B)</td>
      <td>4.1GB</td>
      <td>16GB</td>
      <td>Concise, professional tone</td>
    </tr>
  </tbody>
</table>

<p>For most small business support bots, the 3B model is more than enough. You can always upgrade later by pulling a bigger model — no need to change your workflow.</p>

<h2 id="step-2-set-up-n8n">Step 2: Set Up n8n</h2>

<p>n8n is the workflow automation tool that ties everything together. It receives customer messages, sends them to Ollama, and returns the response.</p>

<p>Add n8n to your <code class="language-plaintext highlighter-rouge">docker-compose.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.8"</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">ollama</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ollama/ollama:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">ollama</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">11434:11434"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">ollama_data:/root/.ollama</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">n8n</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">n8nio/n8n:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">n8n</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">5678:5678"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">N8N_BASIC_AUTH_ACTIVE=true</span>
      <span class="pi">-</span> <span class="s">N8N_BASIC_AUTH_USER=admin</span>
      <span class="pi">-</span> <span class="s">N8N_BASIC_AUTH_PASSWORD=ChangeThisPassword123</span>
      <span class="pi">-</span> <span class="s">WEBHOOK_URL=https://support.yourdomain.com/</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">n8n_data:/home/node/.n8n</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">ollama_data</span><span class="pi">:</span>
  <span class="na">n8n_data</span><span class="pi">:</span>
</code></pre></div></div>

<p><strong>Important:</strong> Change the password before deploying. Use a strong, unique password.</p>

<p>Restart the stack:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">http://YOUR_SERVER_IP:5678</code> in your browser. Log in with the credentials you set. You’re now in the n8n workflow editor.</p>

<h3 id="securing-the-setup">Securing the Setup</h3>

<p>Before going live, put n8n behind a reverse proxy with HTTPS. Using Caddy (the simplest option):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Add Caddy to docker-compose.yml</span>
  caddy:
    image: caddy:latest
    container_name: caddy
    ports:
      - <span class="s2">"80:80"</span>
      - <span class="s2">"443:443"</span>
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    restart: unless-stopped
</code></pre></div></div>

<p>Create a <code class="language-plaintext highlighter-rouge">Caddyfile</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>support.yourdomain.com {
    reverse_proxy n8n:5678
}
</code></pre></div></div>

<p>Caddy automatically provisions and renews Let’s Encrypt TLS certificates. Now your webhook URLs are HTTPS-encrypted end to end.</p>

<h2 id="step-3-build-the-knowledge-base">Step 3: Build the Knowledge Base</h2>

<p>The chatbot is only as good as the information you give it. We’ll create a system prompt that contains your business knowledge.</p>

<p>Create a text file with your FAQs, policies, and common questions. Here’s a template — replace with your own information:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BUSINESS: Acme Widgets Inc.
HOURS: Monday–Friday, 9 AM – 5 PM EST. Closed weekends and holidays.
SHIPPING: Free shipping on orders over $50. Standard delivery 3–5 business days. Express delivery 1–2 business days ($15 extra). We ship to US and Canada only.
RETURNS: 30-day return policy. Items must be unused and in original packaging. Contact support@acmewidgets.com for a return label.
PASSWORD RESET: Go to acmewidgets.com/reset, enter your email, click the link in the email. If you don't receive the email within 5 minutes, check spam folder.
ORDER STATUS: Go to acmewidgets.com/track and enter your order number. Order numbers start with "AW" followed by 6 digits.
REFUNDS: Refunds processed within 5–7 business days to the original payment method.
CONTACT: support@acmewidgets.com or call 1-800-555-0100 during business hours.
ESCALATION: If the customer is angry, mentions legal action, or asks something not covered above, tell them you'll connect them with a human agent.
</code></pre></div></div>

<p>This is your <strong>knowledge base document</strong>. Keep it concise — under 2,000 words works best with smaller models. Every few weeks, review what questions the bot couldn’t answer and add those to this document.</p>

<h2 id="step-4-build-the-n8n-workflow">Step 4: Build the n8n Workflow</h2>

<p>Now for the fun part. In the n8n editor, create a new workflow.</p>

<h3 id="node-1-webhook-trigger">Node 1: Webhook Trigger</h3>

<p>Add a <strong>Webhook</strong> node:</p>
<ul>
  <li><strong>HTTP Method:</strong> POST</li>
  <li><strong>Path:</strong> <code class="language-plaintext highlighter-rouge">chat</code></li>
  <li><strong>Response Mode:</strong> Using ‘Respond to Webhook’ node</li>
</ul>

<p>This creates a URL like <code class="language-plaintext highlighter-rouge">https://support.yourdomain.com/webhook/chat</code>. When a customer sends a message, it hits this endpoint.</p>

<h3 id="node-2-build-the-prompt">Node 2: Build the Prompt</h3>

<p>Add a <strong>Set</strong> node (now called “Edit Fields”) to construct the prompt for Ollama:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"system_prompt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"You are a customer support agent for Acme Widgets Inc. Use ONLY the information below to answer questions. Be friendly, concise, and helpful. If you don't know the answer, say you'll connect them with a human agent. Never make up information.</span><span class="se">\n\n</span><span class="s2">KNOWLEDGE BASE:</span><span class="se">\n</span><span class="s2">"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"user_message"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>In practice, you’ll store the knowledge base text in a separate file or n8n variable and reference it here. This keeps the prompt manageable.</p>

<h3 id="node-3-call-ollama">Node 3: Call Ollama</h3>

<p>Add an <strong>HTTP Request</strong> node:</p>
<ul>
  <li><strong>Method:</strong> POST</li>
  <li><strong>URL:</strong> <code class="language-plaintext highlighter-rouge">http://ollama:11434/api/chat</code></li>
  <li><strong>Body Type:</strong> JSON</li>
</ul>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"llama3.2"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"stream"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"messages"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"role"</span><span class="p">:</span><span class="w"> </span><span class="s2">"system"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"role"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"options"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.3</span><span class="p">,</span><span class="w">
    </span><span class="nl">"num_predict"</span><span class="p">:</span><span class="w"> </span><span class="mi">300</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Why temperature 0.3?</strong> Low temperature means the model produces more consistent, factual responses — exactly what you want for customer support. Higher temperatures produce more creative but less reliable answers.</p>

<p><strong>Why num_predict 300?</strong> This limits the response to roughly 300 tokens (about 200 words). Support responses should be concise. Nobody wants a chatbot that writes essays.</p>

<h3 id="node-4-check-for-escalation">Node 4: Check for Escalation</h3>

<p>Add an <strong>If</strong> node to check whether the response mentions escalation:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"conditions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"string"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"value1"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
        </span><span class="nl">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"contains"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"value2"</span><span class="p">:</span><span class="w"> </span><span class="s2">"human agent"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If the condition is true (the bot wants to escalate), route to a node that:</p>
<ul>
  <li>Sends an email or Mattermost notification to your support team</li>
  <li>Tells the customer a human will follow up shortly</li>
</ul>

<p>If false, route directly to the response node.</p>

<h3 id="node-5-respond-to-webhook">Node 5: Respond to Webhook</h3>

<p>Add a <strong>Respond to Webhook</strong> node:</p>
<ul>
  <li><strong>Respond With:</strong> JSON</li>
</ul>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"reply"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ok"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This sends the chatbot’s response back to whoever called the webhook.</p>

<h3 id="the-complete-workflow">The Complete Workflow</h3>

<p>Here’s what the final workflow looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Webhook (POST /chat)
    │
    ▼
Set Node (build prompt with knowledge base)
    │
    ▼
HTTP Request (call Ollama API)
    │
    ▼
If Node (contains "human agent"?)
    ├── YES → Email Notification → Respond with "escalating" message
    └── NO  → Respond to Webhook with bot reply
</code></pre></div></div>

<p>Save and activate the workflow. Your chatbot endpoint is now live.</p>

<h2 id="step-5-connect-a-web-widget">Step 5: Connect a Web Widget</h2>

<p>The easiest way to add a chat widget to your website is with a small HTML snippet. You don’t need a fancy framework — just a simple form that posts to your webhook:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"chat-widget"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"chat-messages"</span><span class="nt">&gt;&lt;/div&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">id=</span><span class="s">"chat-input"</span> <span class="na">placeholder=</span><span class="s">"Ask a question..."</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">onclick=</span><span class="s">"sendMessage()"</span><span class="nt">&gt;</span>Send<span class="nt">&lt;/button&gt;</span>
<span class="nt">&lt;/div&gt;</span>

<span class="nt">&lt;script&gt;</span>
<span class="k">async</span> <span class="kd">function</span> <span class="nf">sendMessage</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">chat-input</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">messages</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">chat-messages</span><span class="dl">'</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">message</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nf">trim</span><span class="p">();</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">message</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>

  <span class="c1">// Show user message</span>
  <span class="nx">messages</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">+=</span> <span class="s2">`&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; </span><span class="p">${</span><span class="nx">message</span><span class="p">}</span><span class="s2">&lt;/p&gt;`</span><span class="p">;</span>
  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="dl">''</span><span class="p">;</span>

  <span class="c1">// Call the n8n webhook</span>
  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://support.yourdomain.com/webhook/chat</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/json</span><span class="dl">'</span> <span class="p">},</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">({</span> <span class="na">message</span><span class="p">:</span> <span class="nx">message</span> <span class="p">})</span>
  <span class="p">});</span>

  <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>

  <span class="c1">// Show bot response</span>
  <span class="nx">messages</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">+=</span> <span class="s2">`&lt;p&gt;&lt;strong&gt;Bot:&lt;/strong&gt; </span><span class="p">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">reply</span><span class="p">}</span><span class="s2">&lt;/p&gt;`</span><span class="p">;</span>
  <span class="nx">messages</span><span class="p">.</span><span class="nx">scrollTop</span> <span class="o">=</span> <span class="nx">messages</span><span class="p">.</span><span class="nx">scrollHeight</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Add some CSS to style it, and you have a functional chat widget. No JavaScript framework. No npm packages. Just a form that talks to your self-hosted AI.</p>

<h3 id="connecting-via-email-instead">Connecting via Email Instead</h3>

<p>If you’d rather handle email-based support, add an <strong>IMAP Trigger</strong> node in n8n that monitors your support inbox. When a new email arrives, the same workflow runs — Ollama generates a response, and an <strong>Send Email</strong> node replies to the customer. This turns your support@ inbox into an AI-assisted auto-responder.</p>

<h2 id="step-6-test-and-tune">Step 6: Test and Tune</h2>

<p>Before going live, test with real customer questions. Here’s a quick test script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test 1: Basic FAQ</span>
curl <span class="nt">-X</span> POST https://support.yourdomain.com/webhook/chat <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"message": "What are your business hours?"}'</span>

<span class="c"># Test 2: Policy question</span>
curl <span class="nt">-X</span> POST https://support.yourdomain.com/webhook/chat <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"message": "Can I return a product after 40 days?"}'</span>

<span class="c"># Test 3: Escalation trigger</span>
curl <span class="nt">-X</span> POST https://support.yourdomain.com/webhook/chat <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"message": "I want to speak to your manager about a lawsuit"}'</span>
</code></pre></div></div>

<p>Expected behavior:</p>
<ul>
  <li><strong>Test 1:</strong> Responds with your business hours from the knowledge base</li>
  <li><strong>Test 2:</strong> Explains the 30-day return policy and suggests contacting support</li>
  <li><strong>Test 3:</strong> Escalates to a human agent</li>
</ul>

<p>If the bot gives wrong answers, the fix is almost always in the knowledge base document, not the model. Add the missing information and test again. This is the iterative improvement loop:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Bot gives wrong answer
    │
    ▼
Identify what information was missing
    │
    ▼
Add it to the knowledge base document
    │
    ▼
Test the same question again
    │
    ▼
Repeat until accurate
</code></pre></div></div>

<h2 id="monitoring-and-maintenance">Monitoring and Maintenance</h2>

<h3 id="track-what-the-bot-cant-answer">Track What the Bot Can’t Answer</h3>

<p>Add a <strong>Postgres</strong> or <strong>SQLite</strong> node to log every conversation where the bot escalated to a human. Review these weekly — they’re your roadmap for what to add to the knowledge base next.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">escalation_log</span> <span class="p">(</span>
    <span class="n">id</span> <span class="nb">SERIAL</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="nb">timestamp</span> <span class="nb">TIMESTAMP</span> <span class="k">DEFAULT</span> <span class="n">NOW</span><span class="p">(),</span>
    <span class="n">customer_message</span> <span class="nb">TEXT</span><span class="p">,</span>
    <span class="n">bot_response</span> <span class="nb">TEXT</span><span class="p">,</span>
    <span class="n">resolved</span> <span class="nb">BOOLEAN</span> <span class="k">DEFAULT</span> <span class="k">FALSE</span>
<span class="p">);</span>
</code></pre></div></div>

<h3 id="watch-server-resources">Watch Server Resources</h3>

<p>Ollama uses CPU and RAM when generating responses. Monitor with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker stats ollama
</code></pre></div></div>

<p>If responses are slow (more than 5–8 seconds), consider:</p>
<ul>
  <li>Upgrading to a VPS with more CPU cores</li>
  <li>Switching to a smaller model (if accuracy allows)</li>
  <li>Adding a GPU (Ollama supports NVIDIA GPUs natively)</li>
</ul>

<h3 id="update-regularly">Update Regularly</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Update Ollama</span>
docker compose pull ollama <span class="o">&amp;&amp;</span> docker compose up <span class="nt">-d</span> ollama

<span class="c"># Update n8n</span>
docker compose pull n8n <span class="o">&amp;&amp;</span> docker compose up <span class="nt">-d</span> n8n

<span class="c"># Pull a newer model if available</span>
docker <span class="nb">exec</span> <span class="nt">-it</span> ollama ollama pull llama3.2
</code></pre></div></div>

<p>Set a calendar reminder to do this monthly. Updates take about 5 minutes.</p>

<h2 id="cost-breakdown">Cost Breakdown</h2>

<p>Here’s what this setup actually costs per month:</p>

<table>
  <thead>
    <tr>
      <th>Item</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VPS (8GB RAM, 4 cores)</td>
      <td>$15–$25/month</td>
    </tr>
    <tr>
      <td>Domain name (if new)</td>
      <td>$1/month (amortized)</td>
    </tr>
    <tr>
      <td>Ollama</td>
      <td>$0 (open source)</td>
    </tr>
    <tr>
      <td>n8n (self-hosted)</td>
      <td>$0 (open source)</td>
    </tr>
    <tr>
      <td>Caddy (reverse proxy)</td>
      <td>$0 (open source)</td>
    </tr>
    <tr>
      <td>LLM API costs</td>
      <td>$0 (running locally)</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>$15–$25/month</strong></td>
    </tr>
  </tbody>
</table>

<p>Compare that to Intercom’s cheapest plan at $74/month (with limited AI features) or Zendesk Suite at $55/month per agent. Even factoring in the one-time 3–4 hour setup, you break even within the first month.</p>

<h2 id="limitations-be-honest-about-these">Limitations (Be Honest About These)</h2>

<p>This setup is powerful, but it’s not magic. Here’s what it <strong>can’t</strong> do:</p>

<ul>
  <li><strong>Access your database in real-time.</strong> It can’t look up a specific customer’s order. For that, you’d need to add a database query node in n8n that fetches order data and includes it in the prompt.</li>
  <li><strong>Handle complex multi-turn conversations.</strong> Llama 3.2 (3B) is good for single-question responses but loses context in long back-and-forth chats. For multi-turn support, consider Llama 3.1 (8B) and add conversation history to the prompt.</li>
  <li><strong>Understand images or attachments.</strong> The 3B model is text-only. If you need image understanding (e.g., customers sending photos of damaged products), use Llama 3.2 Vision or a multimodal model like LLaVA.</li>
  <li><strong>Be 100% accurate.</strong> All LLMs hallucinate occasionally. The escalation trigger catches most issues, but you should review logs weekly and refine the knowledge base.</li>
</ul>

<h2 id="going-further">Going Further</h2>

<p>Once the basic chatbot is working, here are incremental upgrades you can add:</p>

<ol>
  <li>
    <p><strong>Database integration:</strong> Add a Postgres node in n8n to look up customer orders and include real data in the prompt. This transforms the bot from a FAQ responder to a personalized support agent.</p>
  </li>
  <li>
    <p><strong>Conversation logging:</strong> Store every conversation in a database for quality review. Use Metabase (open source) to build dashboards showing common questions, escalation rates, and response times.</p>
  </li>
  <li>
    <p><strong>Multi-channel:</strong> Connect the same n8n workflow to Telegram, Mattermost, or Signal using their respective n8n trigger nodes. One bot, multiple channels.</p>
  </li>
  <li>
    <p><strong>RAG (Retrieval-Augmented Generation):</strong> For larger knowledge bases (100+ documents), set up a vector database like Qdrant or ChromaDB alongside Ollama. This lets the bot search your documents semantically rather than stuffing everything into the system prompt.</p>
  </li>
  <li>
    <p><strong>Sentiment detection:</strong> Add a second LLM call that classifies sentiment (happy, neutral, frustrated, angry). Route angry customers to humans immediately, before the support bot even tries to respond.</p>
  </li>
</ol>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>A self-hosted customer support chatbot is one of the highest-ROI automation projects a small business can undertake. The setup takes an afternoon, the monthly cost is negligible, and the payoff — hours saved on repetitive questions — is immediate.</p>

<p>The key insight is this: <strong>you don’t need a $500/month AI platform to handle “what are your hours?”</strong> You need a lightweight model, a workflow tool, and a well-maintained knowledge base. The open source tools exist. The hosting is cheap. The only thing standing between you and an automated support bot is a few hours of setup.</p>

<p>And unlike a SaaS platform, this one is yours. No vendor can raise prices on you. No one can shut off your API access. Your customer conversations stay on your server, under your control.</p>

<hr />

<p><em>Ready to automate your customer support without the SaaS price tag? <a href="/">Contact ARDOT Consulting</a> — we help small businesses design, deploy, and maintain open source AI automation that fits their needs and their budget.</em></p>]]></content><author><name>ARDOT Consulting</name></author><category term="chatbot" /><category term="customer-support" /><category term="ollama" /><category term="n8n" /><category term="open-source" /><category term="tutorial" /><category term="self-hosting" /><summary type="html"><![CDATA[Learn how to build a self-hosted customer support chatbot using Ollama and n8n — no API fees, no vendor lock-in, and your customer data never leaves your server.]]></summary></entry></feed>